feat(github): add contributor issue commands

This commit is contained in:
t8y2
2026-07-27 21:47:32 +08:00
parent 1d671ca698
commit a96cf11942
8 changed files with 516 additions and 64 deletions
-59
View File
@@ -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}`);
+260
View File
@@ -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}`);
}
+239
View File
@@ -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);
});
+5 -3
View File
@@ -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 }}