diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..a1d7363 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,22 @@ +{ + "code_actions_on_format": { + "source.fixAll": true, + "source.organizeImports": true + }, + "lsp": { + "vtsls": { + "settings": { + "javascript": { + "suggest": { + "completeJSDocs": true + } + }, + "typescript": { + "suggest": { + "completeJSDocs": true + } + } + } + } + } +} diff --git a/src/events/cleanup/branchCleanup.ts b/src/events/cleanup/branchCleanup.ts new file mode 100644 index 0000000..c763fc0 --- /dev/null +++ b/src/events/cleanup/branchCleanup.ts @@ -0,0 +1,23 @@ +import { defineEvent } from "../../lib/eventHandler.js"; + +export default defineEvent({ + events: ["pull_request.closed"], + callback: async (context) => { + if (!context.payload.pull_request.merged) return; + + const { owner, repo } = context.repo(); + const branchName = context.payload.pull_request.head.ref; + + if (!branchName.startsWith("devhub-bot/")) return; + + try { + await context.octokit.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + } catch (err: any) { + if (err.status !== 422 && err.status !== 404) throw err; + } + }, +}); diff --git a/src/events/commands/addLinter.ts b/src/events/commands/addLinter.ts new file mode 100644 index 0000000..1370838 --- /dev/null +++ b/src/events/commands/addLinter.ts @@ -0,0 +1,129 @@ +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { Context } from "probot"; +import ucid from "unique-custom-id"; +import { fileURLToPath } from "url"; +import { defineEvent } from "../../lib/eventHandler.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const LINT_WORKFLOW_PATHS = [ + ".github/workflows/linter.yaml", + ".github/workflows/linter.yml", +]; + +async function fileExists( + context: Context<"issue_comment.created">, + owner: string, + repo: string, + path: string, +): Promise { + try { + await context.octokit.rest.repos.getContent({ owner, repo, path }); + return true; + } catch { + return false; + } +} + +export default defineEvent({ + events: ["issue_comment.created"], + callback: async (context) => { + const body = context.payload.comment.body.trim(); + + if (!body.startsWith("!addlinter")) return; + + const { owner, repo } = context.repo(); + const username = context.payload.sender.login; + + const { data: permission } = + await context.octokit.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + const allowed = ["admin", "maintain"]; + + if (!allowed.includes(permission.permission)) { + await context.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: context.payload.issue.number, + body: "❌ You must be a repository administrator to use this command.", + }); + + return; + } + + for (const path of LINT_WORKFLOW_PATHS) { + if (await fileExists(context, owner, repo, path)) { + await context.octokit.rest.issues.createComment( + context.issue({ + body: [ + "> [!NOTE]", + `> A lint workflow already exists at \`${path}\`. Nothing to do!`, + ].join("\n"), + }), + ); + return; + } + } + + const branchName = `devhub-bot/${ucid.format("mini")}`; + const workflowPath = ".github/workflows/linter.yaml"; + + const lintWorkflowContent = readFileSync( + join(__dirname, "../../templates/linter.yml"), + "utf-8", + ); + + const encodedContent = Buffer.from(lintWorkflowContent).toString("base64"); + + const { data: mainRef } = await context.octokit.rest.git.getRef({ + owner, + repo, + ref: "heads/main", + }); + const mainSha = mainRef.object.sha; + + await context.octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: mainSha, + }); + + await context.octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path: workflowPath, + message: "ci: add lint workflow", + content: encodedContent, + branch: branchName, + }); + + const { data: pr } = await context.octokit.rest.pulls.create({ + owner, + repo, + title: "ci: add lint workflow", + head: branchName, + base: "main", + body: [ + "This PR adds a GitHub Actions lint workflow to the repository.", + "", + "It was automatically generated by **devhub-bot** in response to the `!addlinter` command.", + "", + ].join("\n"), + }); + + await context.octokit.rest.issues.createComment( + context.issue({ + body: [ + "> [!NOTE]", + `> Done! I've opened a PR to add the lint workflow (#${pr.number})`, + ].join("\n"), + }), + ); + }, +}); diff --git a/src/events/commands/ping.ts b/src/events/commands/ping.ts new file mode 100644 index 0000000..4278d17 --- /dev/null +++ b/src/events/commands/ping.ts @@ -0,0 +1,45 @@ +import { defineEvent } from "../../lib/eventHandler.js"; + +export default defineEvent({ + events: ["issue_comment.created"], + callback: async (context) => { + const body = context.payload.comment.body?.trim().toLowerCase(); + + if (body !== "!ping") return; + + const start = Date.now(); + + const { owner, repo } = context.repo(); + + const latency = Date.now() - start; + const uptime = process.uptime(); + const memory = process.memoryUsage().heapUsed / 1024 / 1024; + + const formatUptime = (s: number) => { + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + return `${d}d ${h}h ${m}m ${sec}s`; + }; + + const reply = [ + "## 🏓 Pong!", + "| Metric | Value |", + "|--------|-------|", + `| Response Time | ${latency} ms |`, + `| Uptime | ${formatUptime(uptime)} |`, + `| Memory Usage | ${memory.toFixed(2)} MB |`, + `| Node.js | ${process.version} |`, + `| Timestamp | ${new Date().toISOString()} |`, + "", + ].join("\n"); + + await context.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: context.payload.issue.number, + body: reply, + }); + }, +}); diff --git a/src/events/issue_comment.created/addLinter.ts b/src/events/issue_comment.created/addLinter.ts deleted file mode 100644 index 3bf2dc1..0000000 --- a/src/events/issue_comment.created/addLinter.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { readFileSync } from "fs"; -import { dirname, join } from "path"; -import { Context } from "probot"; -import ucid from "unique-custom-id"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const LINT_WORKFLOW_PATHS = [ - ".github/workflows/linter.yaml", - ".github/workflows/linter.yml", -]; - -async function fileExists( - context: Context<"issue_comment.created">, - owner: string, - repo: string, - path: string, -): Promise { - try { - await context.octokit.rest.repos.getContent({ owner, repo, path }); - return true; - } catch { - return false; - } -} - -export default async (context: Context<"issue_comment.created">) => { - const body = context.payload.comment.body.trim(); - - if (!body.startsWith("!addlinter")) return; - - const { owner, repo } = context.repo(); - const username = context.payload.sender.login; - - const { data: permission } = - await context.octokit.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username, - }); - - const allowed = ["admin", "maintain"]; - - if (!allowed.includes(permission.permission)) { - await context.octokit.rest.issues.createComment({ - owner, - repo, - issue_number: context.payload.issue.number, - body: "❌ You must be a repository administrator to use this command.", - }); - - return; - } - - for (const path of LINT_WORKFLOW_PATHS) { - if (await fileExists(context, owner, repo, path)) { - await context.octokit.rest.issues.createComment( - context.issue({ - body: [ - "> [!NOTE]", - `> A lint workflow already exists at \`${path}\`. Nothing to do!`, - ].join("\n"), - }), - ); - return; - } - } - - const branchName = `devhub-bot/${ucid.format("mini")}`; - const workflowPath = ".github/workflows/linter.yaml"; - - const lintWorkflowContent = readFileSync( - join(__dirname, "../../templates/linter.yml"), - "utf-8", - ); - - const encodedContent = Buffer.from(lintWorkflowContent).toString("base64"); - - const { data: mainRef } = await context.octokit.rest.git.getRef({ - owner, - repo, - ref: "heads/main", - }); - const mainSha = mainRef.object.sha; - - await context.octokit.rest.git.createRef({ - owner, - repo, - ref: `refs/heads/${branchName}`, - sha: mainSha, - }); - - await context.octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo, - path: workflowPath, - message: "ci: add lint workflow", - content: encodedContent, - branch: branchName, - }); - - const { data: pr } = await context.octokit.rest.pulls.create({ - owner, - repo, - title: "ci: add lint workflow", - head: branchName, - base: "main", - body: [ - "This PR adds a GitHub Actions lint workflow to the repository.", - "", - "It was automatically generated by **devhub-bot** in response to the `!addlinter` command.", - "", - ].join("\n"), - }); - - await context.octokit.rest.issues.createComment( - context.issue({ - body: [ - "> [!NOTE]", - `> Done! I've opened a PR to add the lint workflow (#${pr.number})`, - ].join("\n"), - }), - ); -}; diff --git a/src/events/issue_comment.created/ping.ts b/src/events/issue_comment.created/ping.ts deleted file mode 100644 index ce66fd0..0000000 --- a/src/events/issue_comment.created/ping.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Context } from "probot"; - -export default async (context: Context<"issue_comment.created">) => { - const body = context.payload.comment.body?.trim().toLowerCase(); - - if (body !== "!ping") return; - - const start = Date.now(); - - const { owner, repo } = context.repo(); - - const latency = Date.now() - start; - const uptime = process.uptime(); - const memory = process.memoryUsage().heapUsed / 1024 / 1024; - - const formatUptime = (s: number) => { - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = Math.floor(s % 60); - return `${d}d ${h}h ${m}m ${sec}s`; - }; - - const reply = [ - "## 🏓 Pong!", - "| Metric | Value |", - "|--------|-------|", - `| Response Time | ${latency} ms |`, - `| Uptime | ${formatUptime(uptime)} |`, - `| Memory Usage | ${memory.toFixed(2)} MB |`, - `| Node.js | ${process.version} |`, - `| Timestamp | ${new Date().toISOString()} |`, - "", - ].join("\n"); - - await context.octokit.rest.issues.createComment({ - owner, - repo, - issue_number: context.payload.issue.number, - body: reply, - }); -}; diff --git a/src/events/issues.opened/auto-label.ts b/src/events/issues.opened/auto-label.ts deleted file mode 100644 index b8976de..0000000 --- a/src/events/issues.opened/auto-label.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Context } from "probot"; -import { ISSUE_LABEL_RULES } from "../../config/issueLabelRules.js"; -import { ensureLabelExists } from "../../utils/ensureLabel.js"; -import { titleMatchesKeyword } from "../../utils/textMatch.js"; - -export default async (context: Context<"issues.opened">) => { - const { title } = context.payload.issue; - - const labelsToApply = ISSUE_LABEL_RULES.filter((rule) => - rule.keywords.some((keyword) => titleMatchesKeyword(title, keyword)), - ).map((rule) => rule.label); - - if (labelsToApply.length === 0) return; - - for (const label of labelsToApply) { - await ensureLabelExists(context, label); - } - - await context.octokit.rest.issues.addLabels( - context.issue({ labels: labelsToApply.map((l) => l.name) }), - ); -}; diff --git a/src/events/labeling/label-issue.ts b/src/events/labeling/label-issue.ts new file mode 100644 index 0000000..fb3fb8d --- /dev/null +++ b/src/events/labeling/label-issue.ts @@ -0,0 +1,25 @@ +import { ISSUE_LABEL_RULES } from "../../config/issueLabelRules.js"; +import { defineEvent } from "../../lib/eventHandler.js"; +import { ensureLabelExists } from "../../utils/ensureLabel.js"; +import { titleMatchesKeyword } from "../../utils/textMatch.js"; + +export default defineEvent({ + events: ["issues.opened"], + callback: async (context) => { + const { title } = context.payload.issue; + + const labelsToApply = ISSUE_LABEL_RULES.filter((rule) => + rule.keywords.some((keyword) => titleMatchesKeyword(title, keyword)), + ).map((rule) => rule.label); + + if (labelsToApply.length === 0) return; + + for (const label of labelsToApply) { + await ensureLabelExists(context, label); + } + + await context.octokit.rest.issues.addLabels( + context.issue({ labels: labelsToApply.map((l) => l.name) }), + ); + }, +}); diff --git a/src/events/labeling/label-pr.ts b/src/events/labeling/label-pr.ts new file mode 100644 index 0000000..77c2b2e --- /dev/null +++ b/src/events/labeling/label-pr.ts @@ -0,0 +1,52 @@ +import { COMMIT_TYPE_LABELS } from "../../config/commitTypeLabels.js"; +import { defineEvent } from "../../lib/eventHandler.js"; +import { parseConventionalCommit } from "../../utils/conventionalCommit.js"; +import { ensureLabelExists } from "../../utils/ensureLabel.js"; + +export default defineEvent({ + events: ["pull_request.opened"], + callback: async (context) => { + const { owner, repo } = context.repo(); + const pull_number = context.payload.pull_request.number; + + const commits = await context.octokit.paginate( + context.octokit.rest.pulls.listCommits, + { owner, repo, pull_number, per_page: 100 }, + ); + + const typesFound = new Set(); + let hasBreaking = false; + + for (const commit of commits) { + const message = commit.commit.message.split("\n")[0]; + const parsed = parseConventionalCommit(message); + if (!parsed) continue; + + if (COMMIT_TYPE_LABELS[parsed.type]) typesFound.add(parsed.type); + if (parsed.breaking) hasBreaking = true; + } + + const titleParsed = parseConventionalCommit( + context.payload.pull_request.title, + ); + if (titleParsed) { + if (COMMIT_TYPE_LABELS[titleParsed.type]) + typesFound.add(titleParsed.type); + if (titleParsed.breaking) hasBreaking = true; + } + + if (typesFound.size === 0 && !hasBreaking) return; + + const labelsToApply = [...typesFound].map( + (type) => COMMIT_TYPE_LABELS[type], + ); + + for (const label of labelsToApply) { + await ensureLabelExists(context, label); + } + + await context.octokit.rest.issues.addLabels( + context.issue({ labels: labelsToApply.map((l) => l.name) }), + ); + }, +}); diff --git a/src/events/workflow_run.completed/checklint.ts b/src/events/linting/checklint.ts similarity index 67% rename from src/events/workflow_run.completed/checklint.ts rename to src/events/linting/checklint.ts index 8143517..133cff9 100644 --- a/src/events/workflow_run.completed/checklint.ts +++ b/src/events/linting/checklint.ts @@ -1,4 +1,5 @@ import { Context } from "probot"; +import { defineEvent } from "../../lib/eventHandler.js"; type WorkflowRunContext = Context<"workflow_run.completed">; type WorkflowRun = WorkflowRunContext["payload"]["workflow_run"]; @@ -10,38 +11,46 @@ const LINT_WORKFLOW_NAMES = new Set([ "code quality", ]); -export default async (context: WorkflowRunContext): Promise => { - const workflowName = - context.payload.workflow_run.name?.toLowerCase().trim() ?? ""; +export default defineEvent({ + events: ["workflow_run.completed"], + callback: async (context) => { + const workflowName = + context.payload.workflow_run.name?.toLowerCase().trim() ?? ""; - if (!LINT_WORKFLOW_NAMES.has(workflowName)) { - return; - } + if (!LINT_WORKFLOW_NAMES.has(workflowName)) { + return; + } - const { owner, repo } = context.repo(); - const workflowRun = context.payload.workflow_run; + const { owner, repo } = context.repo(); + const workflowRun = context.payload.workflow_run; - const pullNumber = await resolvePullNumber(context, workflowRun, owner, repo); - if (pullNumber === null) { - context.log.info( - `[lint-bot] Could not resolve a PR for workflow run ${workflowRun.id} ` + - `(branch: ${workflowRun.head_branch ?? "unknown"}, sha: ${workflowRun.head_sha}). Skipping.`, + const pullNumber = await resolvePullNumber( + context, + workflowRun, + owner, + repo, ); - return; - } + if (pullNumber === null) { + context.log.info( + `[LINTER] Could not resolve a PR for workflow run ${workflowRun.id} ` + + `(branch: ${workflowRun.head_branch ?? "unknown"}, sha: ${workflowRun.head_sha}). Skipping.`, + ); + return; + } - const body = - workflowRun.conclusion === "success" - ? buildSuccessComment() - : buildFailureComment(owner, repo, workflowRun.id); + const body = + workflowRun.conclusion === "success" + ? buildSuccessComment() + : buildFailureComment(owner, repo, workflowRun.id); - await context.octokit.rest.issues.createComment({ - owner, - repo, - issue_number: pullNumber, - body, - }); -}; + await context.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body, + }); + }, +}); function buildSuccessComment(): string { return [ @@ -119,7 +128,7 @@ async function resolvePullNumber( return sameRepoMatch.number; } } catch (err) { - context.log.warn(`[lint-bot] Same-repo PR lookup failed: ${String(err)}`); + context.log.warn(`[LINTER] Same-repo PR lookup failed: ${String(err)}`); } try { @@ -133,7 +142,7 @@ async function resolvePullNumber( const forkMatch = prs.find((pr) => pr.head.sha === headSha); return forkMatch?.number ?? null; } catch (err) { - context.log.warn(`[lint-bot] Fork PR lookup failed: ${String(err)}`); + context.log.warn(`[LINTER] Fork PR lookup failed: ${String(err)}`); } return null; diff --git a/src/events/pull_request.closed/branchCleanup.ts b/src/events/pull_request.closed/branchCleanup.ts deleted file mode 100644 index 56e8aa6..0000000 --- a/src/events/pull_request.closed/branchCleanup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Context } from "probot"; - -export default async (context: Context<"pull_request.closed">) => { - if (!context.payload.pull_request.merged) return; - - const { owner, repo } = context.repo(); - const branchName = context.payload.pull_request.head.ref; - - if (!branchName.startsWith("devhub-bot/")) return; - - try { - await context.octokit.rest.git.deleteRef({ - owner, - repo, - ref: `heads/${branchName}`, - }); - } catch (err: any) { - if (err.status !== 422 && err.status !== 404) throw err; - } -}; diff --git a/src/events/pull_request.opened/auto-label.ts b/src/events/pull_request.opened/auto-label.ts deleted file mode 100644 index 246728b..0000000 --- a/src/events/pull_request.opened/auto-label.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Context } from "probot"; -import { COMMIT_TYPE_LABELS } from "../../config/commitTypeLabels.js"; -import { parseConventionalCommit } from "../../utils/conventionalCommit.js"; -import { ensureLabelExists } from "../../utils/ensureLabel.js"; - -export default async (context: Context<"pull_request.opened">) => { - const { owner, repo } = context.repo(); - const pull_number = context.payload.pull_request.number; - - const commits = await context.octokit.paginate( - context.octokit.rest.pulls.listCommits, - { owner, repo, pull_number, per_page: 100 }, - ); - - const typesFound = new Set(); - let hasBreaking = false; - - for (const commit of commits) { - const message = commit.commit.message.split("\n")[0]; - const parsed = parseConventionalCommit(message); - if (!parsed) continue; - - if (COMMIT_TYPE_LABELS[parsed.type]) typesFound.add(parsed.type); - if (parsed.breaking) hasBreaking = true; - } - - const titleParsed = parseConventionalCommit( - context.payload.pull_request.title, - ); - if (titleParsed) { - if (COMMIT_TYPE_LABELS[titleParsed.type]) typesFound.add(titleParsed.type); - if (titleParsed.breaking) hasBreaking = true; - } - - if (typesFound.size === 0 && !hasBreaking) return; - - const labelsToApply = [...typesFound].map((type) => COMMIT_TYPE_LABELS[type]); - - for (const label of labelsToApply) { - await ensureLabelExists(context, label); - } - - await context.octokit.rest.issues.addLabels( - context.issue({ labels: labelsToApply.map((l) => l.name) }), - ); -}; diff --git a/src/events/pull_request.opened/conventional-commits.ts b/src/events/pull_request.opened/conventional-commits.ts deleted file mode 100644 index 308caeb..0000000 --- a/src/events/pull_request.opened/conventional-commits.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Context } from "probot"; -import { parseConventionalCommit } from "../../utils/conventionalCommit.js"; - -export default async (context: Context<"pull_request.opened">) => { - const { owner, repo } = context.repo(); - const pull_number = context.payload.pull_request.number; - const prTitle = context.payload.pull_request.title; - - const problems: string[] = []; - - if (!parseConventionalCommit(prTitle)) { - problems.push( - `PR title does not follow Conventional Commits: \`${prTitle}\``, - ); - } - - const commits = await context.octokit.paginate( - context.octokit.rest.pulls.listCommits, - { owner, repo, pull_number, per_page: 100 }, - ); - - for (const commit of commits) { - const message = commit.commit.message.split("\n")[0]; - if (!parseConventionalCommit(message)) { - problems.push( - `Commit https://github.com/${owner}/${repo}/commit/${commit.sha} does not follow Conventional Commits: \`${message}\``, - ); - } - } - - if (problems.length === 0) return; - - const body = [ - "> [!WARNING]", - "> Some commit messages in this PR do not follow the [**Conventional Commits**](https://conventionalcommits.org/) specification.", - ">", - "> Expected format: `type(scope): description`", - "", - "
", - "📋 Invalid commit messages", - "", - ...problems.map((p) => `- ${p}`), - "", - "
", - "", - "> [!TIP]", - "> **Valid types:**", - "> `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`", - "", - "> [!NOTE]", - "> This is just a friendly reminder and will **not block** the PR from being merged.", - "", - ].join("\n"); - - await context.octokit.rest.issues.createComment(context.issue({ body })); -}; diff --git a/src/events/standards/conventional-commits.ts b/src/events/standards/conventional-commits.ts new file mode 100644 index 0000000..495c54a --- /dev/null +++ b/src/events/standards/conventional-commits.ts @@ -0,0 +1,59 @@ +import { defineEvent } from "../../lib/eventHandler.js"; +import { parseConventionalCommit } from "../../utils/conventionalCommit.js"; + +export default defineEvent({ + events: ["pull_request.opened"], + callback: async (context) => { + const { owner, repo } = context.repo(); + const pull_number = context.payload.pull_request.number; + const prTitle = context.payload.pull_request.title; + + const problems: string[] = []; + + if (!parseConventionalCommit(prTitle)) { + problems.push( + `PR title does not follow Conventional Commits: \`${prTitle}\``, + ); + } + + const commits = await context.octokit.paginate( + context.octokit.rest.pulls.listCommits, + { owner, repo, pull_number, per_page: 100 }, + ); + + for (const commit of commits) { + const message = commit.commit.message.split("\n")[0]; + if (!parseConventionalCommit(message)) { + problems.push( + `Commit https://github.com/${owner}/${repo}/commit/${commit.sha} does not follow Conventional Commits: \`${message}\``, + ); + } + } + + if (problems.length === 0) return; + + const body = [ + "> [!WARNING]", + "> Some commit messages in this PR do not follow the [**Conventional Commits**](https://conventionalcommits.org/) specification.", + ">", + "> Expected format: `type(scope): description`", + "", + "
", + "📋 Invalid commit messages", + "", + ...problems.map((p) => `- ${p}`), + "", + "
", + "", + "> [!TIP]", + "> **Valid types:**", + "> `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`", + "", + "> [!NOTE]", + "> This is just a friendly reminder and will **not block** the PR from being merged.", + "", + ].join("\n"); + + await context.octokit.rest.issues.createComment(context.issue({ body })); + }, +}); diff --git a/src/index.ts b/src/index.ts index bcc2aae..fd4565d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,37 +2,52 @@ import fsp from "fs/promises"; import path from "path"; import { Probot } from "probot"; import { fileURLToPath } from "url"; +import { EventHandlerModule } from "./types/eventHandler.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +async function collectHandlerFiles(dir: string): Promise { + const entries = await fsp.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectHandlerFiles(fullPath))); + continue; + } + + if (entry.name.endsWith(".js") && !entry.name.endsWith(".d.ts")) { + files.push(fullPath); + } + } + + return files; +} + export default async (app: Probot) => { const eventsDir = path.join(__dirname, "events"); - const eventNames = await fsp.readdir(eventsDir); - - for (const eventName of eventNames) { - const eventDirPath = path.join(eventsDir, eventName); - const stat = await fsp.stat(eventDirPath); - if (!stat.isDirectory()) continue; - - const handlerFiles = (await fsp.readdir(eventDirPath)).filter( - (file) => file.endsWith(".js") && !file.endsWith(".d.ts"), - ); - - for (const file of handlerFiles) { - const handlerPath = path.join(eventDirPath, file); - const mod = await import(handlerPath); - const handler = mod.default; - - if (typeof handler !== "function") { - app.log.warn( - `[WARN] Skipped ${eventName}/${file}: no default export function`, - ); - continue; - } - - app.on(eventName as any, handler); - app.log.info(`Registered: ${eventName} → ${file}`); + const handlerFiles = await collectHandlerFiles(eventsDir); + + for (const handlerPath of handlerFiles) { + const mod = await import(handlerPath); + const handler: EventHandlerModule | undefined = mod.default; + const relPath = path.relative(eventsDir, handlerPath); + + if ( + !handler || + typeof handler.callback !== "function" || + !Array.isArray(handler.events) + ) { + app.log.warn(`[WARN] Skipped ${relPath}: missing events[] or callback()`); + continue; + } + + for (const eventName of handler.events) { + app.on(eventName, handler.callback); + app.log.info(`[SUCCESS] Registered: ${eventName} -- ${relPath}`); } } }; diff --git a/src/lib/eventHandler.ts b/src/lib/eventHandler.ts new file mode 100644 index 0000000..fd706a5 --- /dev/null +++ b/src/lib/eventHandler.ts @@ -0,0 +1,8 @@ +import type { EmitterWebhookEventName } from "@octokit/webhooks"; +import { EventHandlerModule } from "../types/eventHandler.js"; + +export function defineEvent( + handler: EventHandlerModule, +): EventHandlerModule { + return handler; +} diff --git a/src/types/eventHandler.ts b/src/types/eventHandler.ts new file mode 100644 index 0000000..406ce48 --- /dev/null +++ b/src/types/eventHandler.ts @@ -0,0 +1,9 @@ +import type { EmitterWebhookEventName } from "@octokit/webhooks"; +import { Context } from "probot"; + +export interface EventHandlerModule< + E extends EmitterWebhookEventName = EmitterWebhookEventName, +> { + events: E[]; + callback: (context: Context) => unknown; +}