From a96cf11942bca1e8b8239b2eba059f446186bc41 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 27 Jul 2026 21:47:32 +0800 Subject: [PATCH] feat(github): add contributor issue commands --- .github/scripts/issue-claim.mjs | 59 ------ .github/scripts/issue-commands.mjs | 260 ++++++++++++++++++++++++ .github/scripts/issue-commands.test.mjs | 239 ++++++++++++++++++++++ .github/workflows/issue-claim.yml | 8 +- CONTRIBUTING.md | 3 +- CONTRIBUTING.zh-CN.md | 3 +- docs/content/docs/contributing.cn.mdx | 4 + docs/content/docs/contributing.mdx | 4 + 8 files changed, 516 insertions(+), 64 deletions(-) delete mode 100644 .github/scripts/issue-claim.mjs create mode 100644 .github/scripts/issue-commands.mjs create mode 100644 .github/scripts/issue-commands.test.mjs diff --git a/.github/scripts/issue-claim.mjs b/.github/scripts/issue-claim.mjs deleted file mode 100644 index 7e8b9ad01..000000000 --- a/.github/scripts/issue-claim.mjs +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); -const issueNumber = process.env.ISSUE_NUMBER; -const commentBody = process.env.COMMENT_BODY || ""; -const commentUser = process.env.COMMENT_USER || ""; -const commentUserType = process.env.COMMENT_USER_TYPE || ""; - -// Only handle /claim as a standalone word -if (!/(?:^|\s)\/claim(?:\s|$)/.test(commentBody)) { - process.exit(0); -} - -// Don't let bots claim -if (commentUserType === "Bot") { - console.log("Commenter is a bot, skipping"); - process.exit(0); -} - -console.log(`/claim from @${commentUser} on #${issueNumber}`); - -async function gh(args) { - const { stdout } = await execFileAsync("gh", args, { maxBuffer: 1024 * 1024 }); - return stdout.trim(); -} - -async function ghJson(args) { - const out = await gh(args); - return JSON.parse(out); -} - -// Check current assignees -const assignees = await ghJson([ - "issue", "view", issueNumber, - "--json", "assignees", - "-q", ".assignees", -]); - -if (assignees.length > 0) { - const names = assignees.map((a) => `@${a.login}`).join(", "); - await gh([ - "issue", "comment", issueNumber, - "--body", `❌ @${commentUser} 这个 issue 已经有人认领了:${names}`, - ]); - process.exit(0); -} - -// Assign -await gh(["issue", "edit", issueNumber, "--add-assignee", commentUser]); - -// Confirm -await gh([ - "issue", "comment", issueNumber, - "--body", `✅ @${commentUser} 已认领 #${issueNumber},开始处理吧!`, -]); - -console.log(`Assigned @${commentUser} to #${issueNumber}`); diff --git a/.github/scripts/issue-commands.mjs b/.github/scripts/issue-commands.mjs new file mode 100644 index 000000000..d61c63870 --- /dev/null +++ b/.github/scripts/issue-commands.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const ISSUE_CLOSE_CONTEXT_QUERY = ` + query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + defaultBranchRef { + name + } + issue(number: $number) { + state + assignees(first: 20) { + nodes { + login + } + } + timelineItems(first: 100, after: $endCursor, itemTypes: [CROSS_REFERENCED_EVENT]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on CrossReferencedEvent { + source { + __typename + ... on PullRequest { + number + url + state + baseRefName + mergedAt + author { + login + } + repository { + nameWithOwner + } + } + } + } + } + } + } + } + } +`; + +function sameLogin(left, right) { + return left?.toLowerCase() === right?.toLowerCase(); +} + +export function detectIssueCommand(commentBody) { + const body = commentBody || ""; + if (/^\s*\/unclaim(?:ed)?\s*$/.test(body)) return "unclaim"; + if (/^\s*\/close\s*$/.test(body)) return "close"; + if (/(?:^|\s)\/claim(?:\s|$)/.test(body)) return "claim"; + return null; +} + +export function findEligibleMergedPullRequest({ context, commentUser, repository }) { + return context.pullRequests + .filter( + (pullRequest) => + pullRequest.state === "MERGED" && + pullRequest.baseRefName === context.defaultBranch && + pullRequest.repository?.nameWithOwner === repository && + sameLogin(pullRequest.author?.login, commentUser), + ) + .sort((left, right) => (right.mergedAt || "").localeCompare(left.mergedAt || ""))[0]; +} + +async function handleClaim({ issueNumber, commentUser, client }) { + const context = await client.getIssueContext(issueNumber); + if (context.assignees.length > 0) { + const names = context.assignees.map(({ login }) => `@${login}`).join(", "); + await client.commentIssue(issueNumber, `❌ @${commentUser} 这个 issue 已经有人认领了:${names}`); + return { status: "already-claimed" }; + } + + await client.addAssignee(issueNumber, commentUser); + await client.commentIssue(issueNumber, `✅ @${commentUser} 已认领 #${issueNumber},开始处理吧!`); + return { status: "claimed" }; +} + +async function handleUnclaim({ issueNumber, commentUser, client }) { + const context = await client.getIssueContext(issueNumber); + if (context.state !== "OPEN") return { status: "already-closed" }; + + const assignee = context.assignees.find(({ login }) => sameLogin(login, commentUser)); + if (!assignee) { + await client.commentIssue( + issueNumber, + `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`, + ); + return { status: "not-assignee" }; + } + + await client.removeAssignee(issueNumber, assignee.login); + await client.commentIssue(issueNumber, `✅ @${commentUser} 已取消认领 #${issueNumber},其他贡献者可以继续认领。`); + return { status: "unclaimed" }; +} + +async function handleClose({ issueNumber, commentUser, repository, client }) { + const context = await client.getIssueCloseContext(issueNumber); + if (context.state !== "OPEN") return { status: "already-closed" }; + + const isAssignee = context.assignees.some(({ login }) => sameLogin(login, commentUser)); + if (!isAssignee) { + await client.commentIssue(issueNumber, `❌ @${commentUser} 只有当前 assignee 可以使用 \`/close\`。`); + return { status: "not-assignee" }; + } + + const pullRequest = findEligibleMergedPullRequest({ context, commentUser, repository }); + if (!pullRequest) { + await client.commentIssue( + issueNumber, + `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`, + ); + return { status: "no-merged-pull-request" }; + } + + await client.closeIssue( + issueNumber, + `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`, + ); + return { status: "closed", pullRequest }; +} + +export async function handleIssueCommand({ + issueNumber, + commentBody, + commentUser, + commentUserType, + repository, + client, +}) { + const command = detectIssueCommand(commentBody); + if (!command) return { status: "ignored" }; + if (commentUserType === "Bot") return { status: "ignored-bot" }; + + if (command === "claim") return handleClaim({ issueNumber, commentUser, client }); + if (command === "unclaim") return handleUnclaim({ issueNumber, commentUser, client }); + return handleClose({ issueNumber, commentUser, repository, client }); +} + +async function gh(args) { + const { stdout } = await execFileAsync("gh", args, { maxBuffer: 1024 * 1024 }); + return stdout.trim(); +} + +export function createGitHubClient({ repository, runGh = gh }) { + const [owner, name, extra] = repository.split("/"); + if (!owner || !name || extra) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); + + return { + async getIssueContext(issueNumber) { + return JSON.parse( + await runGh([ + "issue", + "view", + String(issueNumber), + "--repo", + repository, + "--json", + "state,assignees", + ]), + ); + }, + + async getIssueCloseContext(issueNumber) { + let endCursor; + let context; + + do { + const args = [ + "api", + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${issueNumber}`, + "-f", + `query=${ISSUE_CLOSE_CONTEXT_QUERY}`, + ]; + if (endCursor) args.push("-f", `endCursor=${endCursor}`); + + const response = JSON.parse(await runGh(args)); + const repositoryData = response.data?.repository; + const issue = repositoryData?.issue; + if (!repositoryData || !issue) throw new Error(`Issue #${issueNumber} was not found in ${repository}`); + + if (!context) { + context = { + state: issue.state, + defaultBranch: repositoryData.defaultBranchRef?.name, + assignees: issue.assignees.nodes, + pullRequests: [], + }; + if (!context.defaultBranch) throw new Error(`Default branch was not found for ${repository}`); + } + + context.pullRequests.push( + ...issue.timelineItems.nodes + .map((node) => node.source) + .filter((source) => source?.__typename === "PullRequest"), + ); + + const { hasNextPage, endCursor: nextCursor } = issue.timelineItems.pageInfo; + endCursor = hasNextPage ? nextCursor : undefined; + } while (endCursor); + + return context; + }, + + async addAssignee(issueNumber, assignee) { + await runGh(["issue", "edit", String(issueNumber), "--repo", repository, "--add-assignee", assignee]); + }, + + async removeAssignee(issueNumber, assignee) { + await runGh(["issue", "edit", String(issueNumber), "--repo", repository, "--remove-assignee", assignee]); + }, + + async commentIssue(issueNumber, body) { + await runGh(["issue", "comment", String(issueNumber), "--repo", repository, "--body", body]); + }, + + async closeIssue(issueNumber, comment) { + await runGh([ + "issue", + "close", + String(issueNumber), + "--repo", + repository, + "--reason", + "completed", + "--comment", + comment, + ]); + }, + }; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const repository = process.env.GITHUB_REPOSITORY || ""; + const result = await handleIssueCommand({ + issueNumber: process.env.ISSUE_NUMBER, + commentBody: process.env.COMMENT_BODY || "", + commentUser: process.env.COMMENT_USER || "", + commentUserType: process.env.COMMENT_USER_TYPE || "", + repository, + client: createGitHubClient({ repository }), + }); + console.log(`Issue command result: ${result.status}`); +} diff --git a/.github/scripts/issue-commands.test.mjs b/.github/scripts/issue-commands.test.mjs new file mode 100644 index 000000000..b03e3ff71 --- /dev/null +++ b/.github/scripts/issue-commands.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createGitHubClient, + detectIssueCommand, + findEligibleMergedPullRequest, + handleIssueCommand, +} from "./issue-commands.mjs"; + +const repository = "t8y2/dbx"; + +function issueContext(overrides = {}) { + return { + state: "OPEN", + assignees: [], + ...overrides, + }; +} + +function closeContext(overrides = {}) { + return { + ...issueContext({ assignees: [{ login: "contributor" }] }), + defaultBranch: "main", + pullRequests: [], + ...overrides, + }; +} + +function recordingClient({ issue = issueContext(), close = closeContext() } = {}) { + const calls = []; + return { + calls, + getIssueContext: async () => issue, + getIssueCloseContext: async () => close, + addAssignee: async (issueNumber, assignee) => calls.push({ type: "add", issueNumber, assignee }), + removeAssignee: async (issueNumber, assignee) => calls.push({ type: "remove", issueNumber, assignee }), + commentIssue: async (issueNumber, body) => calls.push({ type: "comment", issueNumber, body }), + closeIssue: async (issueNumber, comment) => calls.push({ type: "close", issueNumber, comment }), + }; +} + +function commandOptions(commentBody, client, overrides = {}) { + return { + issueNumber: "123", + commentBody, + commentUser: "contributor", + commentUserType: "User", + repository, + client, + ...overrides, + }; +} + +test("detectIssueCommand preserves claim syntax and accepts unclaim aliases", () => { + assert.equal(detectIssueCommand("/claim"), "claim"); + assert.equal(detectIssueCommand("please /claim"), "claim"); + assert.equal(detectIssueCommand("/unclaim"), "unclaim"); + assert.equal(detectIssueCommand("/unclaimed"), "unclaim"); + assert.equal(detectIssueCommand("/close"), "close"); + assert.equal(detectIssueCommand("please /close"), null); +}); + +test("claim assigns an available issue and keeps the existing response", async () => { + const client = recordingClient(); + const result = await handleIssueCommand(commandOptions("/claim", client)); + + assert.equal(result.status, "claimed"); + assert.deepEqual(client.calls, [ + { type: "add", issueNumber: "123", assignee: "contributor" }, + { type: "comment", issueNumber: "123", body: "✅ @contributor 已认领 #123,开始处理吧!" }, + ]); +}); + +test("claim rejects an issue that already has an assignee", async () => { + const client = recordingClient({ issue: issueContext({ assignees: [{ login: "maintainer" }] }) }); + const result = await handleIssueCommand(commandOptions("/claim", client)); + + assert.equal(result.status, "already-claimed"); + assert.equal(client.calls.length, 1); + assert.match(client.calls[0].body, /@maintainer/); +}); + +test("unclaim only removes the commenter from the assignee list", async () => { + const client = recordingClient({ + issue: issueContext({ assignees: [{ login: "Contributor" }, { login: "maintainer" }] }), + }); + const result = await handleIssueCommand(commandOptions("/unclaimed", client)); + + assert.equal(result.status, "unclaimed"); + assert.deepEqual(client.calls, [ + { type: "remove", issueNumber: "123", assignee: "Contributor" }, + { + type: "comment", + issueNumber: "123", + body: "✅ @contributor 已取消认领 #123,其他贡献者可以继续认领。", + }, + ]); +}); + +test("unclaim rejects users who are not assigned", async () => { + const client = recordingClient({ issue: issueContext({ assignees: [{ login: "maintainer" }] }) }); + const result = await handleIssueCommand(commandOptions("/unclaim", client)); + + assert.equal(result.status, "not-assignee"); + assert.equal(client.calls.length, 1); + assert.equal(client.calls[0].type, "comment"); +}); + +test("findEligibleMergedPullRequest requires the assignee's merged PR on the default branch", () => { + const eligible = { + number: 105, + state: "MERGED", + baseRefName: "main", + mergedAt: "2026-07-27T08:00:00Z", + author: { login: "Contributor" }, + repository: { nameWithOwner: repository }, + }; + const context = closeContext({ + pullRequests: [ + { ...eligible, number: 101, state: "OPEN" }, + { ...eligible, number: 102, baseRefName: "release" }, + { ...eligible, number: 103, author: { login: "someone-else" } }, + { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, + eligible, + ], + }); + + assert.equal(findEligibleMergedPullRequest({ context, commentUser: "contributor", repository }), eligible); +}); + +test("close keeps the issue open until an eligible PR is merged", async () => { + const client = recordingClient({ + close: closeContext({ + pullRequests: [ + { + number: 1234, + state: "OPEN", + baseRefName: "main", + author: { login: "contributor" }, + repository: { nameWithOwner: repository }, + }, + ], + }), + }); + const result = await handleIssueCommand(commandOptions("/close", client)); + + assert.equal(result.status, "no-merged-pull-request"); + assert.equal(client.calls.length, 1); + assert.equal(client.calls[0].type, "comment"); +}); + +test("close succeeds after the assignee's linked PR is merged", async () => { + const pullRequest = { + number: 1234, + state: "MERGED", + baseRefName: "main", + mergedAt: "2026-07-27T08:00:00Z", + author: { login: "contributor" }, + repository: { nameWithOwner: repository }, + }; + const client = recordingClient({ close: closeContext({ pullRequests: [pullRequest] }) }); + const result = await handleIssueCommand(commandOptions("/close", client)); + + assert.equal(result.status, "closed"); + assert.equal(result.pullRequest, pullRequest); + assert.deepEqual(client.calls, [ + { + type: "close", + issueNumber: "123", + comment: "✅ @contributor 的关联 PR #1234 已合并,关闭 #123。", + }, + ]); +}); + +test("commands ignore bots and closed unclaim requests", async () => { + const botClient = recordingClient(); + const botResult = await handleIssueCommand( + commandOptions("/claim", botClient, { commentUserType: "Bot" }), + ); + assert.equal(botResult.status, "ignored-bot"); + assert.deepEqual(botClient.calls, []); + + const closedClient = recordingClient({ issue: issueContext({ state: "CLOSED" }) }); + const closedResult = await handleIssueCommand(commandOptions("/unclaim", closedClient)); + assert.equal(closedResult.status, "already-closed"); + assert.deepEqual(closedClient.calls, []); +}); + +test("GitHub client aggregates paginated pull request cross references", async () => { + const responses = [ + { + data: { + repository: { + defaultBranchRef: { name: "main" }, + issue: { + state: "OPEN", + assignees: { nodes: [{ login: "contributor" }] }, + timelineItems: { + pageInfo: { hasNextPage: true, endCursor: "next-page" }, + nodes: [{ source: { __typename: "PullRequest", number: 10 } }], + }, + }, + }, + }, + }, + { + data: { + repository: { + defaultBranchRef: { name: "main" }, + issue: { + state: "OPEN", + assignees: { nodes: [{ login: "contributor" }] }, + timelineItems: { + pageInfo: { hasNextPage: false, endCursor: "done" }, + nodes: [ + { source: { __typename: "Issue", number: 11 } }, + { source: { __typename: "PullRequest", number: 12 } }, + ], + }, + }, + }, + }, + }, + ]; + const args = []; + const client = createGitHubClient({ + repository, + runGh: async (commandArgs) => { + args.push(commandArgs); + return JSON.stringify(responses.shift()); + }, + }); + + const context = await client.getIssueCloseContext("123"); + assert.deepEqual(context.pullRequests.map(({ number }) => number), [10, 12]); + assert.equal(args.length, 2); + assert.equal(args[1].includes("endCursor=next-page"), true); +}); diff --git a/.github/workflows/issue-claim.yml b/.github/workflows/issue-claim.yml index 19b029c9c..211d7596a 100644 --- a/.github/workflows/issue-claim.yml +++ b/.github/workflows/issue-claim.yml @@ -1,4 +1,4 @@ -name: Issue Claim +name: Issue Commands on: issue_comment: @@ -11,6 +11,7 @@ concurrency: permissions: contents: read issues: write + pull-requests: read jobs: claim: @@ -28,9 +29,10 @@ jobs: owner: ${{ github.repository_owner }} repositories: dbx permission-issues: write + permission-pull-requests: read - - name: Handle /claim command - run: node .github/scripts/issue-claim.mjs + - name: Handle issue command + run: node .github/scripts/issue-commands.mjs env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ github.event.issue.number }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 907115347..c0a3bd9be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,9 @@ Thanks for taking a look at DBX. Whether you fix a typo, improve docs, or tackle ## Where to Start 1. Browse [open issues](https://github.com/t8y2/dbx/issues) and choose one with no assignee or active contributor in its comments. Do not rely only on labels; read the full report, comments, and screenshots. -2. Comment on the issue you want to work on so others do not duplicate the effort. You can comment `/claim` to claim it. +2. Comment on the issue you want to work on so others do not duplicate the effort. Use `/claim` to claim it, or `/unclaim` (`/unclaimed` is also accepted) later if you cannot continue. 3. Fork the repo, create a branch, and open a PR against `main`. +4. After your linked PR is merged, comment `/close` if the issue remains open. The command only works for the current assignee and the author of the merged PR. If you are not sure what to pick, choose an issue with clear reproduction steps, a small scope, or a database you can verify against a real instance. Follow the [complete website tutorial](https://dbxio.com/en/docs/contributing). diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 44341cc3b..515d34629 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -5,8 +5,9 @@ ## 从哪里开始 1. 浏览 [Issues](https://github.com/t8y2/dbx/issues),选择尚未分配、评论中也没有人正在处理的问题。不要只依赖标签,先阅读完整正文、评论和截图。 -2. 在 Issue 下留言说明你想做什么,避免重复劳动;可以评论 `/claim` 来认领。 +2. 在 Issue 下留言说明你想做什么,避免重复劳动;使用 `/claim` 认领,后续无法继续时使用 `/unclaim` 取消认领,也兼容 `/unclaimed`。 3. Fork 仓库,新建分支开发,然后向 `main` 提 PR。 +4. 关联 PR 合并后,如果 Issue 仍然处于打开状态,可以评论 `/close`。该命令只允许当前 assignee、且必须由关联 PR 作者本人使用。 如果暂时不确定做什么,优先选择复现清晰、改动范围小,或者你能使用真实数据库验证的问题。完整流程见[官网贡献教程](https://dbxio.com/cn/docs/contributing)。 diff --git a/docs/content/docs/contributing.cn.mdx b/docs/content/docs/contributing.cn.mdx index a3830650b..8a0f24571 100644 --- a/docs/content/docs/contributing.cn.mdx +++ b/docs/content/docs/contributing.cn.mdx @@ -33,6 +33,8 @@ description: 从零搭建 DBX 开发环境,运行桌面版,完成第一次 认领成功后,机器人会把 Issue 分配给你。如果你准备采用的方案可能影响现有行为,先在 Issue 中简要说明思路,等维护者确认后再开始大规模修改。 +如果后续不准备继续或暂时无法完成,可以单独评论 `/unclaim`,也兼容 `/unclaimed`。工作流只会移除你自己的 assignee,之后其他贡献者可以重新认领。 + ## 2. 安装开发环境 DBX 桌面版基于 Tauri、Vue 和 Rust。仓库当前要求: @@ -421,6 +423,8 @@ PR 描述至少写清: 提交 PR 后,如果 CI 失败,点击失败任务查看日志,在原分支继续提交修复即可,不需要重新创建 PR。 +关联 PR 合并到默认分支后,如果 Issue 仍然处于打开状态,可以在 Issue 下单独评论 `/close`。工作流只会在评论者是当前 assignee,并且是已合并关联 PR 的作者时关闭 Issue。 + ## 10. 根据 Review 更新代码 维护者提出修改意见后,在同一分支继续修改、提交并推送: diff --git a/docs/content/docs/contributing.mdx b/docs/content/docs/contributing.mdx index 0d3e06da6..d868de13a 100644 --- a/docs/content/docs/contributing.mdx +++ b/docs/content/docs/contributing.mdx @@ -33,6 +33,8 @@ If nobody is working on the issue, post this as a standalone comment: The claim workflow assigns the issue to you when it is available. If your proposed solution changes existing behavior, explain the approach briefly in the issue before making a large patch. +If you later cannot continue, post `/unclaim` as a standalone comment (`/unclaimed` is also accepted). The workflow removes only your own assignment so another contributor can claim the issue. + ## 2. Install the Toolchain DBX Desktop uses Tauri, Vue, and Rust. The repository currently requires: @@ -421,6 +423,8 @@ The pull request description should include: If CI fails, open the failed job and inspect its logs. Push the fix to the same branch; you do not need to create another pull request. +When your linked PR has been merged into the default branch but the issue remains open, post `/close` as a standalone issue comment. The workflow only closes the issue when you are its current assignee and the author of a merged PR that cross-references the issue. + ## 10. Update the Pull Request After Review Continue working on the same branch after receiving review feedback: