feat(github): add contributor issue commands
This commit is contained in:
parent
1d671ca698
commit
a96cf11942
|
|
@ -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}`);
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 更新代码
|
||||
|
||||
维护者提出修改意见后,在同一分支继续修改、提交并推送:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue