From 730c35e8f18603b71cd60ee2e5a7300025b86ed0 Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 13:15:19 +0800 Subject: [PATCH 1/6] fix(workflows): make task automation race-safe --- .github/tests/task-automation.test.cjs | 561 +++++++++++++++++++++++++ .github/workflows/auto-label.yml | 11 +- .github/workflows/task-claim.yml | 176 +++++--- .github/workflows/task-issue-sync.yml | 179 ++++---- 4 files changed, 801 insertions(+), 126 deletions(-) create mode 100644 .github/tests/task-automation.test.cjs diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs new file mode 100644 index 0000000..93f202e --- /dev/null +++ b/.github/tests/task-automation.test.cjs @@ -0,0 +1,561 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const workflowPath = (name) => path.join(repoRoot, '.github', 'workflows', name); +const readWorkflow = (name) => fs.readFileSync(workflowPath(name), 'utf8'); + +const readGithubScript = (name) => { + const source = readWorkflow(name).replace(/\r\n/gu, '\n'); + const lines = source.split('\n'); + const markerIndex = lines.findIndex((line) => /^\s+script:\s*\|\s*$/u.test(line)); + assert.notEqual(markerIndex, -1, `${name} must contain a github-script block`); + + const firstScriptLine = lines.slice(markerIndex + 1).find((line) => line.trim()); + assert.ok(firstScriptLine, `${name} github-script block must not be empty`); + const indent = firstScriptLine.match(/^\s*/u)[0].length; + const scriptLines = []; + for (const line of lines.slice(markerIndex + 1)) { + if (line.trim() && line.match(/^\s*/u)[0].length < indent) break; + scriptLines.push(line.slice(Math.min(indent, line.length))); + } + return scriptLines.join('\n'); +}; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const runWorkflowScript = async (name, { github, context, env = {} }) => { + const warnings = []; + const failures = []; + const core = { + info() {}, + warning(message) { warnings.push(message); }, + setFailed(message) { failures.push(message); }, + }; + const fetch = async () => { + throw new Error('Unexpected fetch call in workflow test'); + }; + const execute = new AsyncFunction( + 'github', + 'context', + 'core', + 'fetch', + 'process', + readGithubScript(name) + ); + await execute(github, context, core, fetch, { env }); + return { warnings, failures }; +}; + +const taskBody = ({ + project = 'Team Project', + status = 'Ready', + risk = 'Normal', + extra = '', +} = {}) => [ + '- 编号:`B-001`', + `- 状态:\`${status}\``, + '- 主责小组:`Backend`', + '- 优先级:`P1`', + '- 批次:`Batch 0`', + '- 模块:`docs`', + '- 预期工时(小时数):`1`', + '- 实际工时(小时数):`0`', + `- Risk:\`${risk}\``, + '- 依赖任务:`无`', + `- GitHub Project:\`${project}\``, + '- Project sync:`pending`', + extra, +].filter(Boolean).join('\n'); + +const taskIssue = (overrides = {}) => ({ + number: 7, + node_id: 'ISSUE_7', + title: '[B-001] Implement API', + body: taskBody(), + state: 'open', + assignees: [], + labels: [], + ...overrides, +}); + +const claimContext = (issue, body = '认领:@alice') => ({ + eventName: 'issue_comment', + repo: { owner: 'acme', repo: 'widgets' }, + payload: { + issue, + comment: { + body, + user: { login: 'alice' }, + author_association: 'MEMBER', + }, + }, +}); + +const createClaimGithub = (latestIssue) => { + const issueVersions = Array.isArray(latestIssue) ? latestIssue : [latestIssue]; + const calls = { + get: [], + addAssignees: [], + update: [], + comments: [], + }; + const github = { + rest: { + issues: { + async get(args) { + calls.get.push(args); + return { data: issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] }; + }, + async addAssignees(args) { calls.addAssignees.push(args); }, + async update(args) { calls.update.push(args); }, + async createComment(args) { calls.comments.push(args); }, + }, + }, + async graphql() { + throw new Error('Project unavailable in claim boundary test'); + }, + }; + return { github, calls }; +}; + +const projectFields = () => [ + singleSelectField('Status', 'Todo'), + singleSelectField('Group', 'Backend'), + singleSelectField('Priority', 'P1'), + singleSelectField('Batch', 'Batch 0'), + singleSelectField('Module', 'docs'), + singleSelectField('Risk', 'Normal'), + typedField('Dependency', 'TEXT'), + typedField('ExpectedHours', 'NUMBER'), + typedField('ActualHours', 'NUMBER'), + typedField('OwnerNote', 'TEXT'), +]; + +function singleSelectField(name, optionName) { + return { + id: `FIELD_${name}`, + name, + dataType: 'SINGLE_SELECT', + options: [{ id: `OPTION_${name}_${optionName}`, name: optionName }], + }; +} + +function typedField(name, dataType) { + return { id: `FIELD_${name}`, name, dataType }; +} + +const createSyncGithub = ({ latestIssue, title = 'Team Project', fields = projectFields() }) => { + const issueVersions = Array.isArray(latestIssue) ? latestIssue : [latestIssue]; + const calls = { + get: [], + graphql: [], + projectMutations: [], + addLabels: [], + update: [], + }; + const listLabelsForRepo = async () => [ + { name: 'team:backend' }, + { name: 'area:docs' }, + ]; + const github = { + rest: { + issues: { + async get(args) { + calls.get.push(args); + return { data: issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] }; + }, + listLabelsForRepo, + async addLabels(args) { calls.addLabels.push(args); }, + async update(args) { calls.update.push(args); }, + }, + }, + async paginate(fn, args) { return fn(args); }, + async graphql(query) { + calls.graphql.push(query); + if (query.includes('addProjectV2ItemById')) { + calls.projectMutations.push('add-item'); + return { addProjectV2ItemById: { item: { id: 'ITEM_7' } } }; + } + if (query.includes('updateProjectV2ItemFieldValue')) { + calls.projectMutations.push('update-field'); + return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM_7' } } }; + } + if (query.includes('projectItems')) { + return { node: { projectItems: { nodes: [] } } }; + } + if (query.includes('projectV2(number')) { + return { + user: { + projectV2: { + id: 'PROJECT_1', + title, + fields: { nodes: fields }, + }, + }, + }; + } + throw new Error(`Unexpected GraphQL operation: ${query}`); + }, + }; + return { github, calls }; +}; + +const syncContext = (eventIssue) => ({ + eventName: 'issues', + repo: { owner: 'acme', repo: 'widgets' }, + payload: { issue: eventIssue }, +}); + +const syncEnv = { + PROJECT_OWNER: 'acme', + PROJECT_OWNER_TYPE: 'user', + PROJECT_NUMBER: '1', + PROJECT_NAME: 'Team Project', + TASK_ISSUE_NUMBER: '', +}; + +const createAutoLabelGithub = ({ pr, linkedIssues, lookupErrors = new Set() }) => { + const calls = { addLabels: [], removeLabel: [] }; + const listLabelsForRepo = async () => [{ name: 'blocked' }]; + const listCommits = async () => []; + const listFiles = async () => []; + const github = { + rest: { + issues: { + listLabelsForRepo, + async get({ issue_number: issueNumber }) { + if (issueNumber === pr.number) return { data: pr }; + if (lookupErrors.has(issueNumber)) throw Object.assign(new Error('lookup failed'), { status: 500 }); + return { data: linkedIssues.get(issueNumber) }; + }, + async addLabels(args) { calls.addLabels.push(args); }, + async removeLabel(args) { calls.removeLabel.push(args); }, + }, + pulls: { listCommits, listFiles }, + repos: { + async getContent() { + return { + data: { + encoding: 'base64', + content: Buffer.from(JSON.stringify({ accountLabels: [], pathLabels: [] })).toString('base64'), + }, + }; + }, + }, + search: { async issuesAndPullRequests() { return { data: { items: [] } }; } }, + }, + async paginate(fn, args) { return fn(args); }, + async graphql() { + return { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: [...linkedIssues.keys()].map((number) => ({ + number, + repository: { name: 'widgets', owner: { login: 'acme' } }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }; + }, + }; + return { github, calls }; +}; + +const autoLabelContext = (pr) => ({ + eventName: 'pull_request_target', + repo: { owner: 'acme', repo: 'widgets' }, + payload: { + pull_request: { + number: pr.number, + base: { sha: 'BASE_SHA' }, + user: { login: 'alice', id: 1 }, + }, + }, +}); + +test('task claim and task sync share one issue-scoped concurrency group', () => { + const concurrencyBlock = (name) => { + const match = readWorkflow(name).replace(/\r\n/gu, '\n').match( + /^concurrency:\n group:\s*(.+)\n cancel-in-progress:\s*(.+)$/mu + ); + assert.ok(match, `${name} must define workflow concurrency`); + return { group: match[1], cancel: match[2] }; + }; + + const claim = concurrencyBlock('task-claim.yml'); + const sync = concurrencyBlock('task-issue-sync.yml'); + assert.equal(claim.group, sync.group); + assert.match(claim.group, /github\.repository_id/u); + assert.match(claim.group, /github\.event\.issue\.number/u); + assert.match(claim.group, /inputs\.issue_number/u); + assert.equal(claim.cancel, 'false'); + assert.equal(sync.cancel, 'false'); +}); + +test('claim refreshes the issue and rejects a concurrent primary assignee', async () => { + const eventIssue = taskIssue(); + const latestIssue = taskIssue({ assignees: [{ login: 'bob' }] }); + const { github, calls } = createClaimGithub(latestIssue); + + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(eventIssue), + env: syncEnv, + }); + + assert.equal(calls.get.length, 1); + assert.equal(calls.addAssignees.length, 0); + assert.equal(calls.update.length, 0); + assert.match(calls.comments.at(-1).body, /@bob/u); +}); + +test('claim only assigns open managed task issues', async (t) => { + await t.test('closed managed task', async () => { + const eventIssue = taskIssue(); + const latestIssue = taskIssue({ state: 'closed' }); + const { github, calls } = createClaimGithub(latestIssue); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(eventIssue), + env: syncEnv, + }); + assert.equal(calls.addAssignees.length, 0); + assert.equal(calls.update.length, 0); + }); + + await t.test('ordinary issue', async () => { + const ordinaryIssue = taskIssue({ title: 'Regular bug report' }); + const { github, calls } = createClaimGithub(ordinaryIssue); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(ordinaryIssue), + env: syncEnv, + }); + assert.equal(calls.addAssignees.length, 0); + assert.equal(calls.update.length, 0); + assert.equal(calls.comments.length, 0); + }); + + await t.test('task title with a different Project marker', async () => { + const otherProjectIssue = taskIssue({ body: taskBody({ project: 'Other Project' }) }); + const { github, calls } = createClaimGithub(otherProjectIssue); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(otherProjectIssue), + env: syncEnv, + }); + assert.equal(calls.addAssignees.length, 0); + assert.equal(calls.update.length, 0); + assert.equal(calls.comments.length, 0); + }); +}); + +test('actual hours only update a managed task and preserve the latest body', async (t) => { + await t.test('ordinary issue is not modified', async () => { + const ordinaryIssue = taskIssue({ title: 'Regular bug report' }); + const { github, calls } = createClaimGithub(ordinaryIssue); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(ordinaryIssue, '实际工时:2'), + env: syncEnv, + }); + assert.equal(calls.update.length, 0); + assert.equal(calls.comments.length, 0); + }); + + await t.test('closed managed task can be corrected', async () => { + const closedIssue = taskIssue({ state: 'closed' }); + const { github, calls } = createClaimGithub(closedIssue); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(closedIssue, '实际工时:2'), + env: syncEnv, + }); + assert.equal(calls.update.length, 1); + assert.match(calls.update[0].body, /- 实际工时(小时数):`2`/u); + }); + + await t.test('body is refreshed again immediately before the update', async () => { + const eventIssue = taskIssue({ body: taskBody({ extra: 'EVENT_ONLY' }) }); + const firstRead = taskIssue({ body: taskBody({ extra: 'FIRST_READ_ONLY' }) }); + const writeBase = taskIssue({ body: taskBody({ extra: 'LATEST_WRITE_BASE' }) }); + const { github, calls } = createClaimGithub([firstRead, writeBase]); + await runWorkflowScript('task-claim.yml', { + github, + context: claimContext(eventIssue, '实际工时:2'), + env: syncEnv, + }); + assert.equal(calls.get.length, 2); + assert.equal(calls.update.length, 1); + assert.match(calls.update[0].body, /LATEST_WRITE_BASE/u); + assert.doesNotMatch(calls.update[0].body, /EVENT_ONLY/u); + assert.doesNotMatch(calls.update[0].body, /FIRST_READ_ONLY/u); + }); +}); + +test('task sync refreshes the body again before writing Project sync', async () => { + const firstRead = taskIssue({ body: taskBody({ extra: 'FIRST_READ_ONLY' }) }); + const writeBase = taskIssue({ body: taskBody({ extra: 'LATEST_WRITE_BASE' }) }); + const fields = projectFields().filter((field) => field.name !== 'OwnerNote'); + const { github, calls } = createSyncGithub({ + latestIssue: [firstRead, writeBase], + fields, + }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(firstRead), + env: syncEnv, + }); + + assert.equal(calls.get.length, 2); + assert.equal(calls.update.length, 1); + assert.match(calls.update[0].body, /LATEST_WRITE_BASE/u); + assert.doesNotMatch(calls.update[0].body, /FIRST_READ_ONLY/u); +}); + +test('task sync always refreshes the issue before deciding whether it is managed', async () => { + const eventIssue = taskIssue(); + const latestIssue = taskIssue({ title: 'Regular bug report' }); + const { github, calls } = createSyncGithub({ latestIssue }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(eventIssue), + env: syncEnv, + }); + + assert.equal(calls.get.length, 1); + assert.equal(calls.graphql.length, 0); + assert.equal(calls.addLabels.length, 0); + assert.equal(calls.update.length, 0); +}); + +test('task sync validates Project identity and the complete schema before mutations', async (t) => { + const expectedOptions = new Map([ + ['Status', 'Todo'], + ['Group', 'Backend'], + ['Priority', 'P1'], + ['Batch', 'Batch 0'], + ['Module', 'docs'], + ['Risk', 'Normal'], + ]); + const expectedTypes = new Map([ + ...[...expectedOptions.keys()].map((name) => [name, 'SINGLE_SELECT']), + ['Dependency', 'TEXT'], + ['ExpectedHours', 'NUMBER'], + ['ActualHours', 'NUMBER'], + ['OwnerNote', 'TEXT'], + ]); + const cases = [ + { + name: 'Project title mismatch', + title: 'Different Project', + fields: projectFields(), + }, + ]; + for (const [fieldName, expectedType] of expectedTypes) { + cases.push({ + name: `missing ${fieldName}`, + title: 'Team Project', + fields: projectFields().filter((field) => field.name !== fieldName), + }); + const wrongType = expectedType === 'TEXT' ? 'NUMBER' : 'TEXT'; + cases.push({ + name: `${fieldName} has wrong type`, + title: 'Team Project', + fields: projectFields().map((field) => ( + field.name === fieldName ? typedField(fieldName, wrongType) : field + )), + }); + } + for (const [fieldName, optionName] of expectedOptions) { + cases.push({ + name: `${fieldName} is missing option ${optionName}`, + title: 'Team Project', + fields: projectFields().map((field) => ( + field.name === fieldName ? singleSelectField(fieldName, 'Wrong option') : field + )), + }); + } + + for (const fixture of cases) { + await t.test(fixture.name, async () => { + const issue = taskIssue(); + const { github, calls } = createSyncGithub({ + latestIssue: issue, + title: fixture.title, + fields: fixture.fields, + }); + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + assert.deepEqual(calls.projectMutations, []); + assert.deepEqual(calls.addLabels, []); + }); + } +}); + +test('task sync mutates the Project after a complete valid schema passes', async () => { + const issue = taskIssue(); + const { github, calls } = createSyncGithub({ latestIssue: issue }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + + assert.equal(calls.addLabels.length, 1); + assert.equal(calls.projectMutations.filter((operation) => operation === 'add-item').length, 1); + assert.equal(calls.projectMutations.filter((operation) => operation === 'update-field').length, 10); + assert.equal(calls.update.length, 1); + assert.match(calls.update[0].body, /- Project sync:`synced`/u); +}); + +test('a PR is blocked when any closing issue is blocked', async () => { + const pr = { number: 42, labels: [] }; + const linkedIssues = new Map([ + [7, taskIssue({ number: 7, body: taskBody({ status: 'Blocked', risk: 'Blocked' }) })], + [8, taskIssue({ number: 8 })], + ]); + const { github, calls } = createAutoLabelGithub({ pr, linkedIssues }); + + await runWorkflowScript('auto-label.yml', { + github, + context: autoLabelContext(pr), + }); + + assert.equal(calls.addLabels.length, 1); + assert.deepEqual(calls.addLabels[0].labels, ['blocked']); + assert.equal(calls.removeLabel.length, 0); +}); + +test('a closing-issue lookup error preserves an existing blocked label', async () => { + const pr = { number: 42, labels: [{ name: 'blocked' }] }; + const linkedIssues = new Map([ + [7, taskIssue({ number: 7 })], + [8, taskIssue({ number: 8 })], + ]); + const { github, calls } = createAutoLabelGithub({ + pr, + linkedIssues, + lookupErrors: new Set([8]), + }); + + const result = await runWorkflowScript('auto-label.yml', { + github, + context: autoLabelContext(pr), + }); + + assert.equal(calls.addLabels.length, 0); + assert.equal(calls.removeLabel.length, 0); + assert.ok(result.warnings.some((message) => message.includes('#8'))); +}); diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index c2bff49..65fb3a9 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -151,17 +151,22 @@ jobs: const prIssue = await getIssue(pullNumber); const linkedIssueNumbers = await getClosingIssueNumbers(pullNumber); let shouldBeBlocked = false; + let lookupFailed = false; if (linkedIssueNumbers.length > 0) { const states = []; for (const issueNumber of linkedIssueNumbers) { try { states.push(issueIsBlocked(await getIssue(issueNumber))); } catch (error) { - core.warning(`Could not read linked issue #${issueNumber}; treating it as not blocked.`); - states.push(false); + lookupFailed = true; + core.warning(`Could not read linked issue #${issueNumber}; preserving the PR's current blocked state.`); } } - shouldBeBlocked = states.every(Boolean); + if (lookupFailed) { + core.warning(`Skipping blocked label sync for PR #${pullNumber} because a closing issue lookup failed.`); + return; + } + shouldBeBlocked = states.some(Boolean); } if (shouldBeBlocked && !hasLabel(prIssue, blockedLabel)) { diff --git a/.github/workflows/task-claim.yml b/.github/workflows/task-claim.yml index 518c1b3..62a56c4 100644 --- a/.github/workflows/task-claim.yml +++ b/.github/workflows/task-claim.yml @@ -8,6 +8,10 @@ permissions: issues: write repository-projects: write +concurrency: + group: task-issue-${{ github.repository_id }}-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + jobs: claim: if: ${{ !github.event.issue.pull_request }} @@ -24,8 +28,7 @@ jobs: with: script: | const comment = context.payload.comment; - const issue = context.payload.issue; - const body = issue.body || ''; + const issueNumber = context.payload.issue.number; const commentBody = (comment.body || '').trim(); const claimMatch = commentBody.match(/^认领\s*[::]\s*@?([A-Za-z0-9-]+)\s*$/u); const actualHoursMatch = commentBody.match(/^实际工时\s*[::]\s*(.*?)\s*$/u); @@ -49,11 +52,15 @@ jobs: const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const cleanValue = (value) => String(value || '').trim().replace(/^`|`$/g, '').replace(/`/g, '').trim(); - const readField = (label) => { + const readFieldFromBody = (issueBody, label) => { const pattern = new RegExp(`^-\\s*${escapeRegExp(label)}\\s*[::]\\s*(.+)$`, 'imu'); - const match = body.match(pattern); + const match = issueBody.match(pattern); return match ? cleanValue(match[1]) : ''; }; + const parseTaskCode = (title) => { + const match = title.trim().match(/^\[([PBFDSQ]-\d{3})\]\s+.+$/u); + return match ? match[1] : ''; + }; const parseHourNumber = (value, label, allowZero = true) => { const normalized = cleanValue(value); if (!/^\d+(?:\.\d+)?$/u.test(normalized)) throw new Error(`${label} must be a number.`); @@ -71,32 +78,51 @@ jobs: } return `${issueBody.trimEnd()}\n${nextLine}\n`; }; + const issueResponse = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + const issue = issueResponse.data; + const body = issue.body || ''; + const taskCode = parseTaskCode(issue.title || ''); + const configuredProject = readFieldFromBody(body, 'GitHub Project'); const createComment = async (message) => { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, + issue_number: issueNumber, body: message, }); }; - const updateIssueBody = async (nextBody) => { - if (nextBody !== body) { + const updateIssueFields = async (updates) => { + const latestResponse = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + const latestIssue = latestResponse.data; + const latestBody = latestIssue.body || ''; + if ( + !parseTaskCode(latestIssue.title || '') || + readFieldFromBody(latestBody, 'GitHub Project') !== projectName + ) { + core.warning(`Issue #${issueNumber} stopped matching the managed-task contract before its body update.`); + return false; + } + let nextBody = latestBody; + for (const update of updates) { + nextBody = replaceOrInsertField(nextBody, update.label, update.value, update.anchors); + } + if (nextBody !== latestBody) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, + issue_number: issueNumber, body: nextBody, }); } - }; - const parseTaskCode = (title) => { - const match = title.trim().match(/^\[([PBFDSQ]-\d{3})\]\s+.+$/u); - return match ? match[1] : ''; - }; - const readFieldFromBody = (issueBody, label) => { - const pattern = new RegExp(`^-\\s*${escapeRegExp(label)}\\s*[::]\\s*(.+)$`, 'imu'); - const match = issueBody.match(pattern); - return match ? cleanValue(match[1]) : ''; + return true; }; const projectGraphql = async (query, variables = {}) => { if (!process.env.PROJECTS_TOKEN) { @@ -120,7 +146,7 @@ jobs: }; const syncProject = async (issueBody, mode, note) => { const taskCode = parseTaskCode(issue.title || ''); - if (!taskCode || !issueBody.includes('GitHub Project') || !issueBody.includes(projectName)) { + if (!taskCode || readFieldFromBody(issueBody, 'GitHub Project') !== projectName) { core.info('Issue does not look like a managed task issue; skipping Project sync.'); return 'pending'; } @@ -149,8 +175,8 @@ jobs: ? 'Done' : ({ Draft: 'Todo', Ready: 'Todo', Blocked: 'In Progress', 'In Progress': 'In Progress', Review: 'In Progress', Done: 'Done' }[issueStatus] || 'Todo'); const projectSelection = projectOwnerType === 'organization' - ? `organization(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name options { id name } } } } } }` - : `user(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name options { id name } } } } } }`; + ? `organization(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } }` + : `user(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } }`; const projectQuery = `query($login: String!, $number: Int!) { ${projectSelection} }`; const itemQuery = `query($issueId: ID!) { node(id: $issueId) { ... on Issue { projectItems(first: 50) { nodes { id project { id title } } } } } }`; const addItemMutation = `mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } }`; @@ -159,45 +185,72 @@ jobs: const ownerNode = projectOwnerType === 'organization' ? projectResult.organization : projectResult.user; const project = ownerNode?.projectV2; if (!project) throw new Error(`Project '${projectName}' was not found for ${projectOwner}.`); + if (project.title !== projectName) { + throw new Error(`Project number ${projectNumber} resolved to '${project.title}', expected '${projectName}'.`); + } const fieldByName = new Map(project.fields.nodes.filter(Boolean).map((field) => [field.name, field])); + const requireField = (fieldName, dataType) => { + const field = fieldByName.get(fieldName); + if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); + if (field.dataType !== dataType) { + throw new Error(`Project field '${fieldName}' must be ${dataType}, found ${field.dataType || 'unknown'}.`); + } + return field; + }; + const singleSelectUpdate = (fieldName, optionName) => { + const field = requireField(fieldName, 'SINGLE_SELECT'); + const option = field.options?.find((candidate) => candidate.name === optionName); + if (!option) throw new Error(`Project field '${fieldName}' option '${optionName}' is unavailable.`); + return { fieldId: field.id, value: { singleSelectOptionId: option.id } }; + }; + const textUpdate = (fieldName, text) => { + const field = requireField(fieldName, 'TEXT'); + return { fieldId: field.id, value: { text } }; + }; + const numberUpdate = (fieldName, hours) => { + const field = requireField(fieldName, 'NUMBER'); + return { fieldId: field.id, value: { number: hours.number } }; + }; + const updatePlan = []; + if (mode === 'claim') { + updatePlan.push( + singleSelectUpdate('Status', projectStatus), + singleSelectUpdate('Group', group), + singleSelectUpdate('Priority', priority), + singleSelectUpdate('Batch', batch), + singleSelectUpdate('Module', moduleName), + singleSelectUpdate('Risk', risk), + textUpdate('Dependency', dependency) + ); + } + updatePlan.push( + numberUpdate('ExpectedHours', expectedHours), + numberUpdate('ActualHours', actualHours), + textUpdate('OwnerNote', note) + ); + const itemResult = await projectGraphql(itemQuery, { issueId: issue.node_id }); let item = itemResult.node.projectItems.nodes.find((node) => node.project.id === project.id); if (!item) { const added = await projectGraphql(addItemMutation, { projectId: project.id, contentId: issue.node_id }); item = added.addProjectV2ItemById.item; } - const updateSingleSelect = async (fieldName, optionName) => { - const field = fieldByName.get(fieldName); - const option = field?.options?.find((candidate) => candidate.name === optionName); - if (!field || !option) throw new Error(`Project field '${fieldName}' option '${optionName}' is unavailable.`); - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value: { singleSelectOptionId: option.id } }); - }; - const updateText = async (fieldName, text) => { - const field = fieldByName.get(fieldName); - if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value: { text } }); - }; - const updateNumber = async (fieldName, hours) => { - const field = fieldByName.get(fieldName); - if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); - const value = field.dataType === 'NUMBER' ? { number: hours.number } : { text: hours.text }; - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value }); - }; - if (mode === 'claim') { - await updateSingleSelect('Status', projectStatus); - await updateSingleSelect('Group', group); - await updateSingleSelect('Priority', priority); - await updateSingleSelect('Batch', batch); - await updateSingleSelect('Module', moduleName); - await updateSingleSelect('Risk', risk); - await updateText('Dependency', dependency); + for (const update of updatePlan) { + await projectGraphql(updateFieldMutation, { + projectId: project.id, + itemId: item.id, + fieldId: update.fieldId, + value: update.value, + }); } - await updateNumber('ExpectedHours', expectedHours); - await updateNumber('ActualHours', actualHours); - await updateText('OwnerNote', note); return 'synced'; }; + if (!taskCode || configuredProject !== projectName) { + core.info(`Issue #${issueNumber} is not a managed task for Project '${projectName}'; skipping task command.`); + return; + } + if (actualHoursMatch) { let actualHours; try { @@ -223,7 +276,14 @@ jobs: core.warning(`Project actual-hours sync failed: ${error.message}`); } nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); - await updateIssueBody(nextBody); + await updateIssueFields([ + { + label: '实际工时(小时数)', + value: actualHours.text, + anchors: ['预期工时(小时数)', '模块'], + }, + { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, + ]); if (syncState === 'blocked') { core.setFailed(`Updated actual hours on issue #${issue.number}, but Project sync is blocked. ${syncErrorMessage}`); } else { @@ -238,7 +298,11 @@ jobs: await createComment(`认领失败:请使用自己的 GitHub 用户名认领,例如 \`认领:@${commenterLogin}\`。`); return; } - const taskStatus = readField('状态') || 'Draft'; + if (issue.state !== 'open') { + await createComment('认领失败:只能认领 open 状态的任务。'); + return; + } + const taskStatus = readFieldFromBody(body, '状态') || 'Draft'; if (['Blocked', 'Review', 'Done'].includes(taskStatus)) { await createComment(`认领失败:当前任务状态为 \`${taskStatus}\`,请先由协调人改为 \`Draft\` 或 \`Ready\` 后再认领。`); return; @@ -249,7 +313,11 @@ jobs: return; } try { - parseHourNumber(readField('预期工时(小时数)') || readField('预期工时'), '预期工时(小时数)', false); + parseHourNumber( + readFieldFromBody(body, '预期工时(小时数)') || readFieldFromBody(body, '预期工时'), + '预期工时(小时数)', + false + ); } catch { await createComment('认领失败:请先把 `预期工时(小时数)` 填为大于 0 的小时数,例如 `0.5`、`1` 或 `2`。'); return; @@ -273,7 +341,11 @@ jobs: core.warning(`Project claim sync failed: ${error.message}`); } nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); - await updateIssueBody(nextBody); + await updateIssueFields([ + { label: '状态', value: 'In Progress', anchors: ['编号'] }, + { label: 'Risk', value: 'Normal', anchors: ['模块'] }, + { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, + ]); if (syncState === 'blocked') { core.setFailed(`Assigned issue #${issue.number} to @${commenterLogin}, but Project sync is blocked. ${syncErrorMessage}`); } else { diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index 77c4752..6e0776e 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -15,6 +15,10 @@ permissions: issues: write repository-projects: write +concurrency: + group: task-issue-${{ github.repository_id }}-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + jobs: sync: if: ${{ github.event_name == 'workflow_dispatch' || !github.event.issue.pull_request }} @@ -84,35 +88,30 @@ jobs: }; const dispatchIssueNumber = Number(process.env.TASK_ISSUE_NUMBER || '0'); - let issue = context.payload.issue; - if (!issue && dispatchIssueNumber > 0) { - const response = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: dispatchIssueNumber, - }); - issue = response.data; - } - if (!issue) throw new Error('Task Issue Sync needs an issue event or workflow_dispatch issue_number.'); + const issueNumber = dispatchIssueNumber > 0 + ? dispatchIssueNumber + : Number(context.payload.issue?.number || '0'); + if (issueNumber <= 0) throw new Error('Task Issue Sync needs an issue event or workflow_dispatch issue_number.'); + const issueResponse = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + const issue = issueResponse.data; const body = issue.body || ''; const parseTaskCode = (title) => { const match = title.trim().match(/^\[([PBFDSQ]-\d{3})\]\s+.+$/u); return match ? match[1] : ''; }; - const taskCode = parseTaskCode(issue.title || ''); - if (!taskCode || !body.includes(`GitHub Project`) || !body.includes(projectName)) { - core.info('Issue does not look like a managed task issue; skipping.'); - return; - } - const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const cleanValue = (value) => String(value || '').trim().replace(/^`|`$/g, '').replace(/`/g, '').trim(); - const readField = (label) => { + const readFieldFromBody = (issueBody, label) => { const pattern = new RegExp(`^-\\s*${escapeRegExp(label)}\\s*[::]\\s*(.+)$`, 'imu'); - const match = body.match(pattern); + const match = issueBody.match(pattern); return match ? cleanValue(match[1]) : ''; }; + const readField = (label) => readFieldFromBody(body, label); const readHourField = (baseLabel) => readField(`${baseLabel}(小时数)`) || readField(baseLabel); const parseHourNumber = (value, label, allowZero) => { const normalized = cleanValue(value || ''); @@ -139,6 +138,11 @@ jobs: } return `${issueBody.trimEnd()}\n${nextLine}\n`; }; + const taskCode = parseTaskCode(issue.title || ''); + if (issue.pull_request || !taskCode || readField('GitHub Project') !== projectName) { + core.info('Issue does not look like a managed task issue; skipping.'); + return; + } const prefix = taskCode[0]; const group = groupByPrefix[prefix]; @@ -159,29 +163,9 @@ jobs: const expectedHours = parseHourNumber(readHourField('预期工时'), '预期工时(小时数)', allowZeroExpected); const actualHours = parseHourNumber(readHourField('实际工时'), '实际工时(小时数)', true); - const labelsToAdd = new Set([groupLabelByGroup[group]].filter(Boolean)); - for (const module of modules) { - if (moduleLabelByModule[module]) labelsToAdd.add(moduleLabelByModule[module]); - } - const existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - }); - const existingLabelNames = new Set(existingLabels.map((label) => label.name)); - const validLabels = [...labelsToAdd].filter((label) => existingLabelNames.has(label)); - if (validLabels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: validLabels, - }); - } - const projectSelection = projectOwnerType === 'organization' - ? `organization(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name options { id name } } } } } }` - : `user(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name options { id name } } } } } }`; + ? `organization(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } }` + : `user(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } }`; const projectQuery = `query($login: String!, $number: Int!) { ${projectSelection} }`; const itemQuery = `query($issueId: ID!) { node(id: $issueId) { ... on Issue { projectItems(first: 50) { nodes { id project { id title } } } } } }`; const addItemMutation = `mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } }`; @@ -194,54 +178,107 @@ jobs: const ownerNode = projectOwnerType === 'organization' ? projectResult.organization : projectResult.user; const project = ownerNode?.projectV2; if (!project) throw new Error(`Project '${projectName}' was not found for ${projectOwner}.`); + if (project.title !== projectName) { + throw new Error(`Project number ${projectNumber} resolved to '${project.title}', expected '${projectName}'.`); + } const fieldByName = new Map(project.fields.nodes.filter(Boolean).map((field) => [field.name, field])); + const requireField = (fieldName, dataType) => { + const field = fieldByName.get(fieldName); + if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); + if (field.dataType !== dataType) { + throw new Error(`Project field '${fieldName}' must be ${dataType}, found ${field.dataType || 'unknown'}.`); + } + return field; + }; + const singleSelectUpdate = (fieldName, optionName) => { + const field = requireField(fieldName, 'SINGLE_SELECT'); + const option = field.options?.find((candidate) => candidate.name === optionName); + if (!option) throw new Error(`Project field '${fieldName}' option '${optionName}' is unavailable.`); + return { fieldId: field.id, value: { singleSelectOptionId: option.id } }; + }; + const textUpdate = (fieldName, text) => { + const field = requireField(fieldName, 'TEXT'); + return { fieldId: field.id, value: { text } }; + }; + const numberUpdate = (fieldName, hours) => { + const field = requireField(fieldName, 'NUMBER'); + return { fieldId: field.id, value: { number: hours.number } }; + }; + const updatePlan = [ + singleSelectUpdate('Status', projectStatus), + singleSelectUpdate('Group', group), + singleSelectUpdate('Priority', priority), + singleSelectUpdate('Batch', batch), + singleSelectUpdate('Module', moduleName), + singleSelectUpdate('Risk', risk), + textUpdate('Dependency', dependency), + numberUpdate('ExpectedHours', expectedHours), + numberUpdate('ActualHours', actualHours), + textUpdate('OwnerNote', `自动同步自 issue #${issue.number};任务编号 ${taskCode}。`), + ]; + + const labelsToAdd = new Set([groupLabelByGroup[group]].filter(Boolean)); + for (const module of modules) { + if (moduleLabelByModule[module]) labelsToAdd.add(moduleLabelByModule[module]); + } + const existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const existingLabelNames = new Set(existingLabels.map((label) => label.name)); + const validLabels = [...labelsToAdd].filter((label) => existingLabelNames.has(label)); + if (validLabels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: validLabels, + }); + } + const itemResult = await projectGraphql(itemQuery, { issueId: issue.node_id }); let item = itemResult.node.projectItems.nodes.find((node) => node.project.id === project.id); if (!item) { const added = await projectGraphql(addItemMutation, { projectId: project.id, contentId: issue.node_id }); item = added.addProjectV2ItemById.item; } - const updateSingleSelect = async (fieldName, optionName) => { - const field = fieldByName.get(fieldName); - const option = field?.options?.find((candidate) => candidate.name === optionName); - if (!field || !option) throw new Error(`Project field '${fieldName}' option '${optionName}' is unavailable.`); - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value: { singleSelectOptionId: option.id } }); - }; - const updateText = async (fieldName, text) => { - const field = fieldByName.get(fieldName); - if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value: { text } }); - }; - const updateNumber = async (fieldName, hours) => { - const field = fieldByName.get(fieldName); - if (!field) throw new Error(`Project field '${fieldName}' is unavailable.`); - const value = field.dataType === 'NUMBER' ? { number: hours.number } : { text: hours.text }; - await projectGraphql(updateFieldMutation, { projectId: project.id, itemId: item.id, fieldId: field.id, value }); - }; - - await updateSingleSelect('Status', projectStatus); - await updateSingleSelect('Group', group); - await updateSingleSelect('Priority', priority); - await updateSingleSelect('Batch', batch); - await updateSingleSelect('Module', moduleName); - await updateSingleSelect('Risk', risk); - await updateText('Dependency', dependency); - await updateNumber('ExpectedHours', expectedHours); - await updateNumber('ActualHours', actualHours); - await updateText('OwnerNote', `自动同步自 issue #${issue.number};任务编号 ${taskCode}。`); + for (const update of updatePlan) { + await projectGraphql(updateFieldMutation, { + projectId: project.id, + itemId: item.id, + fieldId: update.fieldId, + value: update.value, + }); + } syncState = 'synced'; } catch (error) { syncErrorMessage = error.message; core.warning(`Task issue sync failed: ${error.message}`); } - let nextBody = replaceOrInsertField(body, 'Project sync', syncState, ['GitHub Project']); - if (nextBody !== body) { + const latestResponse = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + const latestIssue = latestResponse.data; + const latestBody = latestIssue.body || ''; + const stillManaged = !latestIssue.pull_request && + Boolean(parseTaskCode(latestIssue.title || '')) && + readFieldFromBody(latestBody, 'GitHub Project') === projectName; + if (!stillManaged) { + core.warning(`Issue #${issueNumber} stopped matching the managed-task contract before its body update.`); + } + const nextBody = stillManaged + ? replaceOrInsertField(latestBody, 'Project sync', syncState, ['GitHub Project']) + : latestBody; + if (nextBody !== latestBody) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, + issue_number: issueNumber, body: nextBody, }); } From d13e077c50baa9dc8dc07cf4c32355e4d7050993 Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 13:43:41 +0800 Subject: [PATCH 2/6] fix(workflows): preserve concurrent claim events --- .github/tests/task-automation.test.cjs | 300 +++++++++++++++++++++++-- .github/workflows/docs-check.yml | 8 + .github/workflows/task-claim.yml | 262 ++++++++++++++++++--- .github/workflows/task-issue-sync.yml | 4 - 4 files changed, 518 insertions(+), 56 deletions(-) diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs index 93f202e..31c11bb 100644 --- a/.github/tests/task-automation.test.cjs +++ b/.github/tests/task-automation.test.cjs @@ -80,14 +80,14 @@ const taskIssue = (overrides = {}) => ({ ...overrides, }); -const claimContext = (issue, body = '认领:@alice') => ({ +const claimContext = (issue, body = '认领:@alice', login = 'alice') => ({ eventName: 'issue_comment', repo: { owner: 'acme', repo: 'widgets' }, payload: { issue, comment: { body, - user: { login: 'alice' }, + user: { login }, author_association: 'MEMBER', }, }, @@ -98,6 +98,7 @@ const createClaimGithub = (latestIssue) => { const calls = { get: [], addAssignees: [], + removeAssignees: [], update: [], comments: [], }; @@ -109,6 +110,7 @@ const createClaimGithub = (latestIssue) => { return { data: issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] }; }, async addAssignees(args) { calls.addAssignees.push(args); }, + async removeAssignees(args) { calls.removeAssignees.push(args); }, async update(args) { calls.update.push(args); }, async createComment(args) { calls.comments.push(args); }, }, @@ -146,6 +148,157 @@ function typedField(name, dataType) { return { id: `FIELD_${name}`, name, dataType }; } +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const createBarrier = (parties) => { + let arrivals = 0; + let release; + const released = new Promise((resolve) => { release = resolve; }); + return async () => { + arrivals += 1; + if (arrivals === parties) release(); + await released; + }; +}; + +const claimProjectFields = () => [ + singleSelectField('Status', 'In Progress'), + singleSelectField('Group', 'Backend'), + singleSelectField('Priority', 'P1'), + singleSelectField('Batch', 'Batch 0'), + singleSelectField('Module', 'docs'), + singleSelectField('Risk', 'Normal'), + typedField('Dependency', 'TEXT'), + typedField('ExpectedHours', 'NUMBER'), + typedField('ActualHours', 'NUMBER'), + typedField('OwnerNote', 'TEXT'), +]; + +const createClaimHarness = ({ + initialIssue = taskIssue(), + logins = ['alice'], + synchronizeClaims = false, + synchronizeInitialReads = false, + synchronizeAdds = false, + beforeAdd, + onAdd, + onProjectUpdate, +} = {}) => { + const state = { + issue: clone(initialIssue), + events: [], + nextEventId: 100, + }; + const calls = new Map(logins.map((login) => [login, { + get: [], + addAssignees: [], + removeAssignees: [], + update: [], + comments: [], + projectUpdates: [], + }])); + const initialReadBarrier = (synchronizeClaims || synchronizeInitialReads) + ? createBarrier(logins.length) + : async () => {}; + const addBarrier = (synchronizeClaims || synchronizeAdds) + ? createBarrier(logins.length) + : async () => {}; + + const githubFor = (runnerLogin) => { + const runnerCalls = calls.get(runnerLogin); + let firstGet = true; + let projectUpdateCount = 0; + const listEvents = async () => clone(state.events); + return { + rest: { + issues: { + listEvents, + async get(args) { + runnerCalls.get.push(args); + if (firstGet) { + firstGet = false; + const snapshot = clone(state.issue); + await initialReadBarrier(); + return { data: snapshot }; + } + return { data: clone(state.issue) }; + }, + async addAssignees(args) { + runnerCalls.addAssignees.push(args); + if (beforeAdd) await beforeAdd({ runnerLogin, state }); + if (synchronizeClaims) { + const assignmentIndex = logins.indexOf(runnerLogin); + while (state.events.filter((event) => event.event === 'assigned').length < assignmentIndex) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + for (const login of args.assignees) { + if (!state.issue.assignees.some((assignee) => assignee.login === login)) { + state.issue.assignees.push({ login }); + state.events.push({ + id: state.nextEventId++, + event: 'assigned', + assignee: { login }, + }); + } + } + if (onAdd) await onAdd({ runnerLogin, state }); + await addBarrier(); + }, + async removeAssignees(args) { + runnerCalls.removeAssignees.push(args); + for (const login of args.assignees) { + state.issue.assignees = state.issue.assignees.filter((assignee) => assignee.login !== login); + state.events.push({ + id: state.nextEventId++, + event: 'unassigned', + assignee: { login }, + }); + } + }, + async update(args) { + runnerCalls.update.push(args); + state.issue.body = args.body; + return { data: clone(state.issue) }; + }, + async createComment(args) { runnerCalls.comments.push(args); }, + }, + }, + async paginate(fn, args) { return fn(args); }, + async graphql(query) { + if (query.includes('addProjectV2ItemById')) { + return { addProjectV2ItemById: { item: { id: 'ITEM_7' } } }; + } + if (query.includes('updateProjectV2ItemFieldValue')) { + projectUpdateCount += 1; + runnerCalls.projectUpdates.push(query); + if (onProjectUpdate) { + await onProjectUpdate({ runnerLogin, projectUpdateCount, state }); + } + return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM_7' } } }; + } + if (query.includes('projectItems')) { + return { node: { projectItems: { nodes: [] } } }; + } + if (query.includes('projectV2(number')) { + return { + user: { + projectV2: { + id: 'PROJECT_1', + title: 'Team Project', + fields: { nodes: claimProjectFields() }, + }, + }, + }; + } + throw new Error(`Unexpected GraphQL operation: ${query}`); + }, + }; + }; + + return { state, calls, githubFor }; +}; + const createSyncGithub = ({ latestIssue, title = 'Team Project', fields = projectFields() }) => { const issueVersions = Array.isArray(latestIssue) ? latestIssue : [latestIssue]; const calls = { @@ -278,23 +431,136 @@ const autoLabelContext = (pr) => ({ }, }); -test('task claim and task sync share one issue-scoped concurrency group', () => { - const concurrencyBlock = (name) => { - const match = readWorkflow(name).replace(/\r\n/gu, '\n').match( - /^concurrency:\n group:\s*(.+)\n cancel-in-progress:\s*(.+)$/mu +test('edge-triggered task workflows do not use lossy Actions concurrency queues', () => { + for (const name of ['task-claim.yml', 'task-issue-sync.yml']) { + assert.doesNotMatch( + readWorkflow(name).replace(/\r\n/gu, '\n'), + /^concurrency:\s*$/mu, + `${name} must process every event instead of replacing a pending run` ); - assert.ok(match, `${name} must define workflow concurrency`); - return { group: match[1], cancel: match[2] }; - }; + } +}); + +test('two interleaved claim events are both processed and converge on one winner', async () => { + const logins = ['zara', 'alice']; + const eventIssue = taskIssue(); + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: eventIssue, + logins, + synchronizeClaims: true, + }); + + await Promise.all(logins.map((login) => runWorkflowScript('task-claim.yml', { + github: githubFor(login), + context: claimContext(eventIssue, `认领:@${login}`, login), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }))); + + const winner = state.events.find((event) => event.event === 'assigned').assignee.login; + const loser = logins.find((login) => login !== winner); + assert.equal(winner, 'zara', 'the server-ordered first assignment wins, not login sort order'); + assert.deepEqual(state.issue.assignees.map((assignee) => assignee.login), [winner]); + assert.equal(calls.get(winner).comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get(loser).comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); + assert.equal(calls.get(loser).comments.some((comment) => comment.body.includes('认领失败')), true); + assert.equal(calls.get(winner).removeAssignees.length, 0); + assert.equal(calls.get(loser).removeAssignees.length, 1); + assert.deepEqual(calls.get(loser).removeAssignees[0].assignees, [loser]); + assert.equal(calls.get(loser).projectUpdates.length, 0); +}); + +test('a delayed contender cannot displace a winner with an earlier assignment event', async () => { + const logins = ['zara', 'alice']; + const eventIssue = taskIssue(); + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: eventIssue, + logins, + synchronizeInitialReads: true, + beforeAdd: async ({ runnerLogin, state: shared }) => { + if (runnerLogin !== 'alice') return; + while (!shared.issue.body.includes('- 状态:`In Progress`')) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }, + }); + + await Promise.all(logins.map((login) => runWorkflowScript('task-claim.yml', { + github: githubFor(login), + context: claimContext(eventIssue, `认领:@${login}`, login), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }))); + + assert.deepEqual(state.issue.assignees.map((assignee) => assignee.login), ['zara']); + assert.equal(calls.get('zara').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get('alice').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); + assert.deepEqual(calls.get('alice').removeAssignees[0].assignees, ['alice']); +}); + +test('claim aborts and rolls back itself when the issue closes after assignment', async () => { + const issue = taskIssue(); + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: issue, + onAdd: async ({ state: shared }) => { shared.issue.state = 'closed'; }, + }); + + await runWorkflowScript('task-claim.yml', { + github: githubFor('alice'), + context: claimContext(issue), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }); - const claim = concurrencyBlock('task-claim.yml'); - const sync = concurrencyBlock('task-issue-sync.yml'); - assert.equal(claim.group, sync.group); - assert.match(claim.group, /github\.repository_id/u); - assert.match(claim.group, /github\.event\.issue\.number/u); - assert.match(claim.group, /inputs\.issue_number/u); - assert.equal(claim.cancel, 'false'); - assert.equal(sync.cancel, 'false'); + assert.deepEqual(state.issue.assignees, []); + assert.equal(calls.get('alice').removeAssignees.length, 1); + assert.equal(calls.get('alice').projectUpdates.length, 0); + assert.equal(calls.get('alice').comments.some((comment) => comment.body.includes('已认领本任务')), false); +}); + +test('claim does not report success when the managed marker disappears before body update', async () => { + const issue = taskIssue(); + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: issue, + onProjectUpdate: async ({ projectUpdateCount, state: shared }) => { + if (projectUpdateCount === 10) shared.issue.body = taskBody({ project: 'Other Project' }); + }, + }); + + await runWorkflowScript('task-claim.yml', { + github: githubFor('alice'), + context: claimContext(issue), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }); + + assert.deepEqual(state.issue.assignees, []); + assert.equal(calls.get('alice').removeAssignees.length, 1); + assert.equal(calls.get('alice').comments.some((comment) => comment.body.includes('已认领本任务')), false); +}); + +test('actual-hours update does not report success when its final managed check fails', async () => { + const issue = taskIssue(); + const { calls, githubFor } = createClaimHarness({ + initialIssue: issue, + onProjectUpdate: async ({ projectUpdateCount, state: shared }) => { + if (projectUpdateCount === 3) shared.issue.body = taskBody({ project: 'Other Project' }); + }, + }); + + await runWorkflowScript('task-claim.yml', { + github: githubFor('alice'), + context: claimContext(issue, '实际工时:2'), + env: syncEnv, + }); + + assert.equal(calls.get('alice').comments.some((comment) => comment.body.includes('实际工时已更新')), false); +}); + +test('workflow regression tests are executed by CI with a pinned Node setup action', () => { + const docsCheck = readWorkflow('docs-check.yml'); + assert.match( + docsCheck, + /actions\/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f/u + ); + assert.match(docsCheck, /node-version:\s*['"]22\.19\.0['"]/u); + assert.match(docsCheck, /node --test \.github\/tests\/\*\.test\.cjs/u); }); test('claim refreshes the issue and rejects a concurrent primary assignee', async () => { diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 5191f09..4ea9692 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -24,6 +24,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '22.19.0' + + - name: Test workflow automation + run: node --test .github/tests/*.test.cjs + - name: Validate labeler JSON run: python -m json.tool .github/labeler.json > /tmp/labeler.json diff --git a/.github/workflows/task-claim.yml b/.github/workflows/task-claim.yml index 62a56c4..a8d4488 100644 --- a/.github/workflows/task-claim.yml +++ b/.github/workflows/task-claim.yml @@ -8,10 +8,6 @@ permissions: issues: write repository-projects: write -concurrency: - group: task-issue-${{ github.repository_id }}-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: false - jobs: claim: if: ${{ !github.event.issue.pull_request }} @@ -78,12 +74,20 @@ jobs: } return `${issueBody.trimEnd()}\n${nextLine}\n`; }; - const issueResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - const issue = issueResponse.data; + const getIssue = async () => { + const response = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + return response.data; + }; + const isManagedTask = (candidate) => { + const candidateBody = candidate.body || ''; + return Boolean(parseTaskCode(candidate.title || '')) && + readFieldFromBody(candidateBody, 'GitHub Project') === projectName; + }; + const issue = await getIssue(); const body = issue.body || ''; const taskCode = parseTaskCode(issue.title || ''); const configuredProject = readFieldFromBody(body, 'GitHub Project'); @@ -95,18 +99,10 @@ jobs: body: message, }); }; - const updateIssueFields = async (updates) => { - const latestResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - const latestIssue = latestResponse.data; + const updateIssueFields = async (updates, validate = () => true) => { + const latestIssue = await getIssue(); const latestBody = latestIssue.body || ''; - if ( - !parseTaskCode(latestIssue.title || '') || - readFieldFromBody(latestBody, 'GitHub Project') !== projectName - ) { + if (!isManagedTask(latestIssue) || !validate(latestIssue)) { core.warning(`Issue #${issueNumber} stopped matching the managed-task contract before its body update.`); return false; } @@ -124,6 +120,102 @@ jobs: } return true; }; + const normalizeLogin = (login) => String(login || '').toLowerCase(); + const claimSettleDelayMs = Number.isFinite(Number(process.env.CLAIM_SETTLE_DELAY_MS)) + ? Math.max(0, Number(process.env.CLAIM_SETTLE_DELAY_MS)) + : 1000; + const waitForClaimSettle = async () => { + await new Promise((resolve) => setTimeout(resolve, claimSettleDelayMs)); + }; + const readAssignmentSnapshot = async () => { + const latestIssue = await getIssue(); + const events = await github.paginate(github.rest.issues.listEvents, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const activeAssignments = new Map(); + const orderedEvents = [...events].sort((left, right) => { + const timeDifference = Date.parse(left.created_at || 0) - Date.parse(right.created_at || 0); + if (timeDifference !== 0) return timeDifference; + return Number(left.id || 0) - Number(right.id || 0); + }); + for (const event of orderedEvents) { + const login = normalizeLogin(event.assignee?.login); + if (!login) continue; + if (event.event === 'assigned' && !activeAssignments.has(login)) { + activeAssignments.set(login, event); + } else if (event.event === 'unassigned') { + activeAssignments.delete(login); + } + } + const assigneeLogins = (latestIssue.assignees || []).map((assignee) => normalizeLogin(assignee.login)); + const assigneeSet = new Set(assigneeLogins); + const ledgerStable = assigneeSet.size === activeAssignments.size && + [...assigneeSet].every((login) => activeAssignments.has(login)); + const activeCandidates = [...activeAssignments.entries()] + .filter(([login]) => assigneeSet.has(login)) + .sort((left, right) => { + const timeDifference = Date.parse(left[1].created_at || 0) - Date.parse(right[1].created_at || 0); + if (timeDifference !== 0) return timeDifference; + const idDifference = Number(left[1].id || 0) - Number(right[1].id || 0); + return idDifference || left[0].localeCompare(right[0]); + }); + return { + issue: latestIssue, + assigneeLogins, + activeAssignments, + ledgerStable, + winnerLogin: activeCandidates[0]?.[0] || '', + }; + }; + const validateClaimIssue = (candidate) => { + if (candidate.state !== 'open') return { ok: false, reason: '任务在认领过程中已关闭。' }; + if (!isManagedTask(candidate)) return { ok: false, reason: '任务已不再符合受管任务规则。' }; + const candidateBody = candidate.body || ''; + const status = readFieldFromBody(candidateBody, '状态') || 'Draft'; + if (['Blocked', 'Review', 'Done'].includes(status)) { + return { ok: false, reason: `任务状态已变为 \`${status}\`。` }; + } + try { + parseHourNumber( + readFieldFromBody(candidateBody, '预期工时(小时数)') || readFieldFromBody(candidateBody, '预期工时'), + '预期工时(小时数)', + false + ); + } catch { + return { ok: false, reason: '预期工时已不再是大于 0 的数字。' }; + } + return { ok: true }; + }; + const waitForClaimControl = async (login, requireSoleAssignee) => { + const normalizedLogin = normalizeLogin(login); + let lastSnapshot; + for (let attempt = 0; attempt < 20; attempt += 1) { + const snapshot = await readAssignmentSnapshot(); + lastSnapshot = snapshot; + const eligibility = validateClaimIssue(snapshot.issue); + if (!eligibility.ok) return { ok: false, reason: eligibility.reason, snapshot }; + if (!snapshot.ledgerStable || !snapshot.activeAssignments.has(normalizedLogin)) { + await waitForClaimSettle(); + continue; + } + if (snapshot.winnerLogin !== normalizedLogin) { + return { + ok: false, + reason: `并发认领由 @${snapshot.winnerLogin} 先完成。`, + snapshot, + }; + } + if (requireSoleAssignee && snapshot.assigneeLogins.length !== 1) { + await waitForClaimSettle(); + continue; + } + return { ok: true, snapshot }; + } + return { ok: false, reason: '等待并发认领收敛超时。', snapshot: lastSnapshot }; + }; const projectGraphql = async (query, variables = {}) => { if (!process.env.PROJECTS_TOKEN) { return github.graphql(query, variables); @@ -144,8 +236,8 @@ jobs: } return payload.data; }; - const syncProject = async (issueBody, mode, note) => { - const taskCode = parseTaskCode(issue.title || ''); + const syncProject = async (currentIssue, issueBody, mode, note) => { + const taskCode = parseTaskCode(currentIssue.title || ''); if (!taskCode || readFieldFromBody(issueBody, 'GitHub Project') !== projectName) { core.info('Issue does not look like a managed task issue; skipping Project sync.'); return 'pending'; @@ -171,7 +263,7 @@ jobs: '实际工时(小时数)', true ); - const projectStatus = issue.state === 'closed' + const projectStatus = currentIssue.state === 'closed' ? 'Done' : ({ Draft: 'Todo', Ready: 'Todo', Blocked: 'In Progress', 'In Progress': 'In Progress', Review: 'In Progress', Done: 'Done' }[issueStatus] || 'Todo'); const projectSelection = projectOwnerType === 'organization' @@ -229,10 +321,10 @@ jobs: textUpdate('OwnerNote', note) ); - const itemResult = await projectGraphql(itemQuery, { issueId: issue.node_id }); + const itemResult = await projectGraphql(itemQuery, { issueId: currentIssue.node_id }); let item = itemResult.node.projectItems.nodes.find((node) => node.project.id === project.id); if (!item) { - const added = await projectGraphql(addItemMutation, { projectId: project.id, contentId: issue.node_id }); + const added = await projectGraphql(addItemMutation, { projectId: project.id, contentId: currentIssue.node_id }); item = added.addProjectV2ItemById.item; } for (const update of updatePlan) { @@ -270,13 +362,13 @@ jobs: let syncState = 'blocked'; let syncErrorMessage = ''; try { - syncState = await syncProject(nextBody, 'hours', `@${comment.user.login} 通过 issue 评论更新实际工时。`); + syncState = await syncProject(issue, nextBody, 'hours', `@${comment.user.login} 通过 issue 评论更新实际工时。`); } catch (error) { syncErrorMessage = error.message; core.warning(`Project actual-hours sync failed: ${error.message}`); } nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); - await updateIssueFields([ + const issueFieldsUpdated = await updateIssueFields([ { label: '实际工时(小时数)', value: actualHours.text, @@ -284,6 +376,10 @@ jobs: }, { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, ]); + if (!issueFieldsUpdated) { + core.setFailed(`Actual-hours update for issue #${issueNumber} was aborted because the task contract changed.`); + return; + } if (syncState === 'blocked') { core.setFailed(`Updated actual hours on issue #${issue.number}, but Project sync is blocked. ${syncErrorMessage}`); } else { @@ -322,6 +418,8 @@ jobs: await createComment('认领失败:请先把 `预期工时(小时数)` 填为大于 0 的小时数,例如 `0.5`、`1` 或 `2`。'); return; } + let addedByThisRun = false; + let ownAssignmentEventId = null; if (!(issue.assignees || []).some((assignee) => assignee.login.toLowerCase() === commenterLogin.toLowerCase())) { await github.rest.issues.addAssignees({ owner: context.repo.owner, @@ -329,23 +427,117 @@ jobs: issue_number: issue.number, assignees: [commenterLogin], }); + addedByThisRun = true; } - let nextBody = replaceOrInsertField(body, '状态', 'In Progress', ['编号']); + + const rememberOwnAssignment = (control) => { + const ownEvent = control.snapshot?.activeAssignments?.get(normalizeLogin(commenterLogin)); + if (addedByThisRun && ownAssignmentEventId === null && ownEvent) { + ownAssignmentEventId = ownEvent.id; + } + }; + const rollbackOwnAssignment = async (allowPromotion) => { + if (!addedByThisRun) return 'not-owned'; + for (let attempt = 0; attempt < 20; attempt += 1) { + const snapshot = await readAssignmentSnapshot(); + const normalizedLogin = normalizeLogin(commenterLogin); + if (!snapshot.ledgerStable) { + await waitForClaimSettle(); + continue; + } + const activeEvent = snapshot.activeAssignments.get(normalizedLogin); + if (!activeEvent || !snapshot.assigneeLogins.includes(normalizedLogin)) return 'already-removed'; + if (ownAssignmentEventId === null) ownAssignmentEventId = activeEvent.id; + if (String(activeEvent.id) !== String(ownAssignmentEventId)) { + core.setFailed(`Refusing to remove @${commenterLogin}: its active assignment no longer belongs to this run.`); + return 'ownership-changed'; + } + if (allowPromotion && snapshot.winnerLogin === normalizedLogin) return 'promoted'; + await github.rest.issues.removeAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + assignees: [commenterLogin], + }); + return 'removed'; + } + core.setFailed(`Could not confirm @${commenterLogin}'s assignment ownership before rollback.`); + return 'unstable'; + }; + const rejectAfterAssignment = async (control, allowPromotion = false) => { + rememberOwnAssignment(control); + const rollbackResult = await rollbackOwnAssignment(allowPromotion); + if (rollbackResult === 'promoted') return false; + const latestIssue = control.snapshot?.issue; + if (latestIssue && isManagedTask(latestIssue)) { + await createComment(`认领失败:${control.reason}`); + } else { + core.warning(`Claim for issue #${issueNumber} failed after assignment: ${control.reason}`); + } + return true; + }; + const canPromoteFrom = (control) => Boolean( + control.snapshot?.winnerLogin && + control.snapshot.winnerLogin !== normalizeLogin(commenterLogin) && + validateClaimIssue(control.snapshot.issue).ok + ); + + let claimControl; + while (true) { + claimControl = await waitForClaimControl(commenterLogin, true); + rememberOwnAssignment(claimControl); + if (claimControl.ok) break; + if (await rejectAfterAssignment(claimControl, canPromoteFrom(claimControl))) return; + } + + const claimIssue = claimControl.snapshot.issue; + const claimBody = claimIssue.body || ''; + let nextBody = replaceOrInsertField(claimBody, '状态', 'In Progress', ['编号']); nextBody = replaceOrInsertField(nextBody, 'Risk', 'Normal', ['模块']); let syncState = 'blocked'; let syncErrorMessage = ''; try { - syncState = await syncProject(nextBody, 'claim', `@${commenterLogin} 通过 issue 评论认领本任务。`); + syncState = await syncProject(claimIssue, nextBody, 'claim', `@${commenterLogin} 通过 issue 评论认领本任务。`); } catch (error) { syncErrorMessage = error.message; core.warning(`Project claim sync failed: ${error.message}`); } nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); - await updateIssueFields([ - { label: '状态', value: 'In Progress', anchors: ['编号'] }, - { label: 'Risk', value: 'Normal', anchors: ['模块'] }, - { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, - ]); + let issueFieldsUpdated = false; + for (let attempt = 0; attempt < 3 && !issueFieldsUpdated; attempt += 1) { + const finalControl = await waitForClaimControl(commenterLogin, true); + rememberOwnAssignment(finalControl); + if (!finalControl.ok) { + await rejectAfterAssignment(finalControl, canPromoteFrom(finalControl)); + return; + } + issueFieldsUpdated = await updateIssueFields([ + { label: '状态', value: 'In Progress', anchors: ['编号'] }, + { label: 'Risk', value: 'Normal', anchors: ['模块'] }, + { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, + ], (candidate) => { + const eligibility = validateClaimIssue(candidate); + const assignees = (candidate.assignees || []).map((assignee) => normalizeLogin(assignee.login)); + return eligibility.ok && assignees.length === 1 && assignees[0] === normalizeLogin(commenterLogin); + }); + } + if (!issueFieldsUpdated) { + await rejectAfterAssignment({ + reason: '任务在最终写入前发生了状态或归属冲突。', + snapshot: { issue: await getIssue() }, + }); + return; + } + const committedControl = await waitForClaimControl(commenterLogin, true); + rememberOwnAssignment(committedControl); + const committedStatus = readFieldFromBody(committedControl.snapshot?.issue?.body || '', '状态'); + if (!committedControl.ok || committedStatus !== 'In Progress') { + await rejectAfterAssignment({ + ...committedControl, + reason: committedControl.ok ? '任务状态未能保持为 `In Progress`。' : committedControl.reason, + }, canPromoteFrom(committedControl)); + return; + } if (syncState === 'blocked') { core.setFailed(`Assigned issue #${issue.number} to @${commenterLogin}, but Project sync is blocked. ${syncErrorMessage}`); } else { diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index 6e0776e..fa66608 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -15,10 +15,6 @@ permissions: issues: write repository-projects: write -concurrency: - group: task-issue-${{ github.repository_id }}-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: false - jobs: sync: if: ${{ github.event_name == 'workflow_dispatch' || !github.event.issue.pull_request }} From b0b6ee9c66375154f3556437623a6c6d4231986d Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 13:56:01 +0800 Subject: [PATCH 3/6] fix(workflows): bind claims to comment tokens --- .github/tests/task-automation.test.cjs | 195 +++++++++++++++-- .github/workflows/task-claim.yml | 79 ++++++- .github/workflows/task-issue-sync.yml | 289 ++++++++++++++++--------- 3 files changed, 434 insertions(+), 129 deletions(-) diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs index 31c11bb..31d5b51 100644 --- a/.github/tests/task-automation.test.cjs +++ b/.github/tests/task-automation.test.cjs @@ -80,15 +80,23 @@ const taskIssue = (overrides = {}) => ({ ...overrides, }); -const claimContext = (issue, body = '认领:@alice', login = 'alice') => ({ +const claimContext = ( + issue, + body = '认领:@alice', + login = 'alice', + commentId = 1000, + createdAt = '2026-07-10T01:00:00Z' +) => ({ eventName: 'issue_comment', repo: { owner: 'acme', repo: 'widgets' }, payload: { issue, comment: { + id: commentId, body, user: { login }, author_association: 'MEMBER', + created_at: createdAt, }, }, }); @@ -177,17 +185,29 @@ const claimProjectFields = () => [ const createClaimHarness = ({ initialIssue = taskIssue(), logins = ['alice'], + runnerLogins = {}, synchronizeClaims = false, synchronizeInitialReads = false, synchronizeAdds = false, beforeAdd, onAdd, onProjectUpdate, + initialEvents = [], + initialComments, } = {}) => { const state = { issue: clone(initialIssue), - events: [], - nextEventId: 100, + events: clone(initialEvents), + comments: clone(initialComments || logins.map((runnerKey, index) => { + const login = runnerLogins[runnerKey] || runnerKey; + return { + id: 1000 + index, + body: `认领:@${login}`, + user: { login }, + created_at: `2026-07-10T01:00:${String(index).padStart(2, '0')}Z`, + }; + })), + nextEventId: Math.max(99, ...initialEvents.map((event) => Number(event.id || 0))) + 1, }; const calls = new Map(logins.map((login) => [login, { get: [], @@ -204,15 +224,18 @@ const createClaimHarness = ({ ? createBarrier(logins.length) : async () => {}; - const githubFor = (runnerLogin) => { - const runnerCalls = calls.get(runnerLogin); + const githubFor = (runnerKey) => { + const runnerLogin = runnerLogins[runnerKey] || runnerKey; + const runnerCalls = calls.get(runnerKey); let firstGet = true; let projectUpdateCount = 0; const listEvents = async () => clone(state.events); + const listComments = async () => clone(state.comments); return { rest: { issues: { listEvents, + listComments, async get(args) { runnerCalls.get.push(args); if (firstGet) { @@ -227,7 +250,7 @@ const createClaimHarness = ({ runnerCalls.addAssignees.push(args); if (beforeAdd) await beforeAdd({ runnerLogin, state }); if (synchronizeClaims) { - const assignmentIndex = logins.indexOf(runnerLogin); + const assignmentIndex = logins.indexOf(runnerKey); while (state.events.filter((event) => event.event === 'assigned').length < assignmentIndex) { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -239,6 +262,7 @@ const createClaimHarness = ({ id: state.nextEventId++, event: 'assigned', assignee: { login }, + created_at: '2026-07-10T01:05:00Z', }); } } @@ -253,6 +277,7 @@ const createClaimHarness = ({ id: state.nextEventId++, event: 'unassigned', assignee: { login }, + created_at: '2026-07-10T01:10:00Z', }); } }, @@ -261,7 +286,15 @@ const createClaimHarness = ({ state.issue.body = args.body; return { data: clone(state.issue) }; }, - async createComment(args) { runnerCalls.comments.push(args); }, + async createComment(args) { + runnerCalls.comments.push(args); + state.comments.push({ + id: 9000 + state.comments.length, + body: args.body, + user: { login: 'github-actions[bot]' }, + created_at: '2026-07-10T01:20:00Z', + }); + }, }, }, async paginate(fn, args) { return fn(args); }, @@ -299,12 +332,20 @@ const createClaimHarness = ({ return { state, calls, githubFor }; }; -const createSyncGithub = ({ latestIssue, title = 'Team Project', fields = projectFields() }) => { - const issueVersions = Array.isArray(latestIssue) ? latestIssue : [latestIssue]; +const createSyncGithub = ({ + latestIssue, + title = 'Team Project', + fields = projectFields(), + onFieldUpdate, +}) => { + const usesIssueSequence = Array.isArray(latestIssue); + const issueVersions = usesIssueSequence ? latestIssue : [latestIssue]; + let currentIssue = clone(issueVersions[0]); const calls = { get: [], graphql: [], projectMutations: [], + fieldUpdates: [], addLabels: [], update: [], }; @@ -317,15 +358,21 @@ const createSyncGithub = ({ latestIssue, title = 'Team Project', fields = projec issues: { async get(args) { calls.get.push(args); - return { data: issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] }; + const issue = usesIssueSequence + ? issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] + : currentIssue; + return { data: clone(issue) }; }, listLabelsForRepo, async addLabels(args) { calls.addLabels.push(args); }, - async update(args) { calls.update.push(args); }, + async update(args) { + calls.update.push(args); + if (!usesIssueSequence) currentIssue.body = args.body; + }, }, }, async paginate(fn, args) { return fn(args); }, - async graphql(query) { + async graphql(query, variables = {}) { calls.graphql.push(query); if (query.includes('addProjectV2ItemById')) { calls.projectMutations.push('add-item'); @@ -333,6 +380,14 @@ const createSyncGithub = ({ latestIssue, title = 'Team Project', fields = projec } if (query.includes('updateProjectV2ItemFieldValue')) { calls.projectMutations.push('update-field'); + calls.fieldUpdates.push(clone(variables)); + if (onFieldUpdate) { + await onFieldUpdate({ + count: calls.fieldUpdates.length, + variables, + setIssue(issue) { currentIssue = clone(issue); }, + }); + } return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM_7' } } }; } if (query.includes('projectItems')) { @@ -450,9 +505,15 @@ test('two interleaved claim events are both processed and converge on one winner synchronizeClaims: true, }); - await Promise.all(logins.map((login) => runWorkflowScript('task-claim.yml', { + await Promise.all(logins.map((login, index) => runWorkflowScript('task-claim.yml', { github: githubFor(login), - context: claimContext(eventIssue, `认领:@${login}`, login), + context: claimContext( + eventIssue, + `认领:@${login}`, + login, + 1000 + index, + `2026-07-10T01:00:${String(index).padStart(2, '0')}Z` + ), env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, }))); @@ -484,9 +545,15 @@ test('a delayed contender cannot displace a winner with an earlier assignment ev }, }); - await Promise.all(logins.map((login) => runWorkflowScript('task-claim.yml', { + await Promise.all(logins.map((login, index) => runWorkflowScript('task-claim.yml', { github: githubFor(login), - context: claimContext(eventIssue, `认领:@${login}`, login), + context: claimContext( + eventIssue, + `认领:@${login}`, + login, + 1000 + index, + `2026-07-10T01:00:${String(index).padStart(2, '0')}Z` + ), env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, }))); @@ -496,6 +563,72 @@ test('a delayed contender cannot displace a winner with an earlier assignment ev assert.deepEqual(calls.get('alice').removeAssignees[0].assignees, ['alice']); }); +test('two claim comments for the same login bind one assignment epoch to one comment token', async () => { + const runnerKeys = ['first', 'second']; + const issue = taskIssue(); + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: issue, + logins: runnerKeys, + runnerLogins: { first: 'alice', second: 'alice' }, + synchronizeClaims: true, + }); + + await Promise.all(runnerKeys.map((runnerKey, index) => runWorkflowScript('task-claim.yml', { + github: githubFor(runnerKey), + context: claimContext( + issue, + '认领:@alice', + 'alice', + 1000 + index, + `2026-07-10T01:00:${String(index).padStart(2, '0')}Z` + ), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }))); + + assert.deepEqual(state.issue.assignees.map((assignee) => assignee.login), ['alice']); + assert.equal(calls.get('first').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get('second').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); + assert.equal(calls.get('second').removeAssignees.length, 0); + assert.equal(calls.get('second').projectUpdates.length, 0); +}); + +test('a new comment owns a reassigned login after the previous assignment epoch ends', async () => { + const issue = taskIssue({ assignees: [{ login: 'alice' }] }); + const comments = [ + { + id: 900, + body: '认领:@alice', + user: { login: 'alice' }, + created_at: '2026-07-10T00:05:00Z', + }, + { + id: 1000, + body: '认领:@alice', + user: { login: 'alice' }, + created_at: '2026-07-10T01:00:00Z', + }, + ]; + const events = [ + { id: 100, event: 'assigned', assignee: { login: 'alice' }, created_at: '2026-07-10T00:10:00Z' }, + { id: 101, event: 'unassigned', assignee: { login: 'alice' }, created_at: '2026-07-10T00:20:00Z' }, + { id: 102, event: 'assigned', assignee: { login: 'alice' }, created_at: '2026-07-10T01:05:00Z' }, + ]; + const { calls, githubFor } = createClaimHarness({ + initialIssue: issue, + initialEvents: events, + initialComments: comments, + }); + + await runWorkflowScript('task-claim.yml', { + github: githubFor('alice'), + context: claimContext(issue, '认领:@alice', 'alice', 1000, '2026-07-10T01:00:00Z'), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }); + + assert.equal(calls.get('alice').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get('alice').removeAssignees.length, 0); +}); + test('claim aborts and rolls back itself when the issue closes after assignment', async () => { const issue = taskIssue(); const { state, calls, githubFor } = createClaimHarness({ @@ -702,6 +835,36 @@ test('task sync always refreshes the issue before deciding whether it is managed assert.equal(calls.update.length, 0); }); +test('task sync retries a stale Project write and converges to the latest source fingerprint', async () => { + const oldIssue = taskIssue(); + const newIssue = taskIssue({ + body: taskBody().replace( + '- 实际工时(小时数):`0`', + '- 实际工时(小时数):`5`' + ), + }); + const { github, calls } = createSyncGithub({ + latestIssue: oldIssue, + onFieldUpdate: async ({ count, setIssue }) => { + if (count === 1) setIssue(newIssue); + }, + }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(oldIssue), + env: syncEnv, + }); + + const actualHoursWrites = calls.fieldUpdates.filter( + (update) => update.fieldId === 'FIELD_ActualHours' + ); + assert.ok(calls.fieldUpdates.length > 10, 'the stale attempt must be followed by a full retry'); + assert.equal(actualHoursWrites.at(-1).value.number, 5); + assert.match(calls.update.at(-1).body, /- 实际工时(小时数):`5`/u); + assert.match(calls.update.at(-1).body, /- Project sync:`synced`/u); +}); + test('task sync validates Project identity and the complete schema before mutations', async (t) => { const expectedOptions = new Map([ ['Status', 'Todo'], diff --git a/.github/workflows/task-claim.yml b/.github/workflows/task-claim.yml index a8d4488..de8cf76 100644 --- a/.github/workflows/task-claim.yml +++ b/.github/workflows/task-claim.yml @@ -24,6 +24,7 @@ jobs: with: script: | const comment = context.payload.comment; + const claimTokenId = String(comment.id || ''); const issueNumber = context.payload.issue.number; const commentBody = (comment.body || '').trim(); const claimMatch = commentBody.match(/^认领\s*[::]\s*@?([A-Za-z0-9-]+)\s*$/u); @@ -129,13 +130,22 @@ jobs: }; const readAssignmentSnapshot = async () => { const latestIssue = await getIssue(); - const events = await github.paginate(github.rest.issues.listEvents, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); + const [events, comments] = await Promise.all([ + github.paginate(github.rest.issues.listEvents, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }), + github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }), + ]); const activeAssignments = new Map(); + const latestUnassigned = new Map(); const orderedEvents = [...events].sort((left, right) => { const timeDifference = Date.parse(left.created_at || 0) - Date.parse(right.created_at || 0); if (timeDifference !== 0) return timeDifference; @@ -145,11 +155,39 @@ jobs: const login = normalizeLogin(event.assignee?.login); if (!login) continue; if (event.event === 'assigned' && !activeAssignments.has(login)) { - activeAssignments.set(login, event); + activeAssignments.set(login, { ...event, claimTokenId: '' }); } else if (event.event === 'unassigned') { activeAssignments.delete(login); + latestUnassigned.set(login, event); } } + const orderedClaimComments = comments + .map((candidate) => { + const match = String(candidate.body || '').trim().match(/^认领\s*[::]\s*@?([A-Za-z0-9-]+)\s*$/u); + const authorLogin = normalizeLogin(candidate.user?.login); + const requestedLogin = normalizeLogin(match?.[1]); + return match && authorLogin === requestedLogin + ? { ...candidate, claimLogin: requestedLogin } + : null; + }) + .filter(Boolean) + .sort((left, right) => { + const timeDifference = Date.parse(left.created_at || 0) - Date.parse(right.created_at || 0); + if (timeDifference !== 0) return timeDifference; + return Number(left.id || 0) - Number(right.id || 0); + }); + for (const [login, assignment] of activeAssignments) { + const lastUnassigned = latestUnassigned.get(login); + const lowerBound = lastUnassigned + ? Date.parse(lastUnassigned.created_at || 0) + : Number.NEGATIVE_INFINITY; + const upperBound = Date.parse(assignment.created_at || 0); + const token = orderedClaimComments.find((candidate) => { + const createdAt = Date.parse(candidate.created_at || 0); + return candidate.claimLogin === login && createdAt > lowerBound && createdAt <= upperBound; + }); + assignment.claimTokenId = token ? String(token.id) : ''; + } const assigneeLogins = (latestIssue.assignees || []).map((assignee) => normalizeLogin(assignee.login)); const assigneeSet = new Set(assigneeLogins); const ledgerStable = assigneeSet.size === activeAssignments.size && @@ -189,7 +227,7 @@ jobs: } return { ok: true }; }; - const waitForClaimControl = async (login, requireSoleAssignee) => { + const waitForClaimControl = async (login, tokenId, requireSoleAssignee) => { const normalizedLogin = normalizeLogin(login); let lastSnapshot; for (let attempt = 0; attempt < 20; attempt += 1) { @@ -201,6 +239,14 @@ jobs: await waitForClaimSettle(); continue; } + const ownAssignment = snapshot.activeAssignments.get(normalizedLogin); + if (!tokenId || ownAssignment.claimTokenId !== String(tokenId)) { + return { + ok: false, + reason: `该 assignment epoch 已由 comment #${ownAssignment.claimTokenId || 'unknown'} 获得。`, + snapshot, + }; + } if (snapshot.winnerLogin !== normalizedLogin) { return { ok: false, @@ -398,6 +444,10 @@ jobs: await createComment('认领失败:只能认领 open 状态的任务。'); return; } + if (!claimTokenId) { + await createComment('认领失败:无法读取本次评论 ID,请重新发布认领评论。'); + return; + } const taskStatus = readFieldFromBody(body, '状态') || 'Draft'; if (['Blocked', 'Review', 'Done'].includes(taskStatus)) { await createComment(`认领失败:当前任务状态为 \`${taskStatus}\`,请先由协调人改为 \`Draft\` 或 \`Ready\` 后再认领。`); @@ -432,7 +482,11 @@ jobs: const rememberOwnAssignment = (control) => { const ownEvent = control.snapshot?.activeAssignments?.get(normalizeLogin(commenterLogin)); - if (addedByThisRun && ownAssignmentEventId === null && ownEvent) { + if ( + addedByThisRun && + ownAssignmentEventId === null && + ownEvent?.claimTokenId === claimTokenId + ) { ownAssignmentEventId = ownEvent.id; } }; @@ -447,6 +501,7 @@ jobs: } const activeEvent = snapshot.activeAssignments.get(normalizedLogin); if (!activeEvent || !snapshot.assigneeLogins.includes(normalizedLogin)) return 'already-removed'; + if (activeEvent.claimTokenId !== claimTokenId) return 'not-owned'; if (ownAssignmentEventId === null) ownAssignmentEventId = activeEvent.id; if (String(activeEvent.id) !== String(ownAssignmentEventId)) { core.setFailed(`Refusing to remove @${commenterLogin}: its active assignment no longer belongs to this run.`); @@ -484,7 +539,7 @@ jobs: let claimControl; while (true) { - claimControl = await waitForClaimControl(commenterLogin, true); + claimControl = await waitForClaimControl(commenterLogin, claimTokenId, true); rememberOwnAssignment(claimControl); if (claimControl.ok) break; if (await rejectAfterAssignment(claimControl, canPromoteFrom(claimControl))) return; @@ -505,7 +560,7 @@ jobs: nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); let issueFieldsUpdated = false; for (let attempt = 0; attempt < 3 && !issueFieldsUpdated; attempt += 1) { - const finalControl = await waitForClaimControl(commenterLogin, true); + const finalControl = await waitForClaimControl(commenterLogin, claimTokenId, true); rememberOwnAssignment(finalControl); if (!finalControl.ok) { await rejectAfterAssignment(finalControl, canPromoteFrom(finalControl)); @@ -528,7 +583,7 @@ jobs: }); return; } - const committedControl = await waitForClaimControl(commenterLogin, true); + const committedControl = await waitForClaimControl(commenterLogin, claimTokenId, true); rememberOwnAssignment(committedControl); const committedStatus = readFieldFromBody(committedControl.snapshot?.issue?.body || '', '状态'); if (!committedControl.ok || committedStatus !== 'In Progress') { diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index fa66608..e6adf2c 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -88,14 +88,14 @@ jobs: ? dispatchIssueNumber : Number(context.payload.issue?.number || '0'); if (issueNumber <= 0) throw new Error('Task Issue Sync needs an issue event or workflow_dispatch issue_number.'); - const issueResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - const issue = issueResponse.data; - - const body = issue.body || ''; + const getIssue = async () => { + const response = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + return response.data; + }; const parseTaskCode = (title) => { const match = title.trim().match(/^\[([PBFDSQ]-\d{3})\]\s+.+$/u); return match ? match[1] : ''; @@ -107,8 +107,6 @@ jobs: const match = issueBody.match(pattern); return match ? cleanValue(match[1]) : ''; }; - const readField = (label) => readFieldFromBody(body, label); - const readHourField = (baseLabel) => readField(`${baseLabel}(小时数)`) || readField(baseLabel); const parseHourNumber = (value, label, allowZero) => { const normalized = cleanValue(value || ''); if (!normalized || normalized === '待估' || normalized === '未填写') { @@ -134,30 +132,89 @@ jobs: } return `${issueBody.trimEnd()}\n${nextLine}\n`; }; - const taskCode = parseTaskCode(issue.title || ''); - if (issue.pull_request || !taskCode || readField('GitHub Project') !== projectName) { - core.info('Issue does not look like a managed task issue; skipping.'); - return; - } + const isManagedIssue = (issue) => { + const issueBody = issue.body || ''; + return !issue.pull_request && + Boolean(parseTaskCode(issue.title || '')) && + readFieldFromBody(issueBody, 'GitHub Project') === projectName; + }; + const buildSyncSource = (issue) => { + if (!isManagedIssue(issue)) return null; + const issueBody = issue.body || ''; + const readField = (label) => readFieldFromBody(issueBody, label); + const readHourField = (baseLabel) => readField(`${baseLabel}(小时数)`) || readField(baseLabel); + const taskCode = parseTaskCode(issue.title || ''); + const prefix = taskCode[0]; + const group = groupByPrefix[prefix]; + const issueStatus = readField('状态') || 'Draft'; + const priority = readField('优先级') || 'P1'; + const batch = readField('批次') || 'Batch 0'; + const modules = (readField('模块') || 'docs') + .split(/\s*(?:\/|/|,|,|、|\+|&)\s*/u) + .map(cleanValue) + .filter(Boolean); + const moduleName = modules[0] || 'docs'; + const risk = issueStatus === 'Blocked' + ? 'Blocked' + : (issueStatus === 'Draft' ? (readField('Risk') || 'Needs Decision') : (readField('Risk') || 'Normal')); + const dependency = readField('依赖任务') || '无'; + const projectStatus = issue.state === 'closed' + ? 'Done' + : ({ Draft: 'Todo', Ready: 'Todo', Blocked: 'In Progress', 'In Progress': 'In Progress', Review: 'In Progress', Done: 'Done' }[issueStatus] || 'Todo'); + const expectedHours = parseHourNumber( + readHourField('预期工时'), + '预期工时(小时数)', + issueStatus === 'Draft' + ); + const actualHours = parseHourNumber(readHourField('实际工时'), '实际工时(小时数)', true); + const fingerprint = JSON.stringify({ + title: String(issue.title || '').trim(), + state: issue.state, + taskCode, + project: readField('GitHub Project'), + issueStatus, + group, + priority, + batch, + modules, + risk, + dependency, + expectedHours: expectedHours.number, + actualHours: actualHours.number, + }); + return { + issue, + body: issueBody, + fingerprint, + taskCode, + group, + issueStatus, + priority, + batch, + modules, + moduleName, + risk, + dependency, + projectStatus, + expectedHours, + actualHours, + }; + }; - const prefix = taskCode[0]; - const group = groupByPrefix[prefix]; - const issueStatus = readField('状态') || 'Draft'; - const priority = readField('优先级') || 'P1'; - const batch = readField('批次') || 'Batch 0'; - const modules = (readField('模块') || 'docs') - .split(/\s*(?:\/|/|,|,|、|\+|&)\s*/u) - .map(cleanValue) - .filter(Boolean); - const moduleName = modules[0] || 'docs'; - const risk = issueStatus === 'Blocked' ? 'Blocked' : (issueStatus === 'Draft' ? (readField('Risk') || 'Needs Decision') : (readField('Risk') || 'Normal')); - const dependency = readField('依赖任务') || '无'; - const projectStatus = issue.state === 'closed' - ? 'Done' - : ({ Draft: 'Todo', Ready: 'Todo', Blocked: 'In Progress', 'In Progress': 'In Progress', Review: 'In Progress', Done: 'Done' }[issueStatus] || 'Todo'); - const allowZeroExpected = issueStatus === 'Draft'; - const expectedHours = parseHourNumber(readHourField('预期工时'), '预期工时(小时数)', allowZeroExpected); - const actualHours = parseHourNumber(readHourField('实际工时'), '实际工时(小时数)', true); + class SourceChangedError extends Error { + constructor(source) { + super('Task issue source changed during Project sync.'); + this.source = source; + } + } + const readCurrentSource = async () => buildSyncSource(await getIssue()); + const assertSourceCurrent = async (expectedFingerprint) => { + const currentSource = await readCurrentSource(); + if (!currentSource || currentSource.fingerprint !== expectedFingerprint) { + throw new SourceChangedError(currentSource); + } + return currentSource; + }; const projectSelection = projectOwnerType === 'organization' ? `organization(login: $login) { projectV2(number: $number) { id title fields(first: 100) { nodes { ... on ProjectV2Field { id name dataType } ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } }` @@ -167,17 +224,7 @@ jobs: const addItemMutation = `mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } }`; const updateFieldMutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: $value }) { projectV2Item { id } } }`; - let syncState = 'blocked'; - let syncErrorMessage = ''; - try { - const projectResult = await projectGraphql(projectQuery, { login: projectOwner, number: projectNumber }); - const ownerNode = projectOwnerType === 'organization' ? projectResult.organization : projectResult.user; - const project = ownerNode?.projectV2; - if (!project) throw new Error(`Project '${projectName}' was not found for ${projectOwner}.`); - if (project.title !== projectName) { - throw new Error(`Project number ${projectNumber} resolved to '${project.title}', expected '${projectName}'.`); - } - + const buildUpdatePlan = (project, source) => { const fieldByName = new Map(project.fields.nodes.filter(Boolean).map((field) => [field.name, field])); const requireField = (fieldName, dataType) => { const field = fieldByName.get(fieldName); @@ -201,21 +248,22 @@ jobs: const field = requireField(fieldName, 'NUMBER'); return { fieldId: field.id, value: { number: hours.number } }; }; - const updatePlan = [ - singleSelectUpdate('Status', projectStatus), - singleSelectUpdate('Group', group), - singleSelectUpdate('Priority', priority), - singleSelectUpdate('Batch', batch), - singleSelectUpdate('Module', moduleName), - singleSelectUpdate('Risk', risk), - textUpdate('Dependency', dependency), - numberUpdate('ExpectedHours', expectedHours), - numberUpdate('ActualHours', actualHours), - textUpdate('OwnerNote', `自动同步自 issue #${issue.number};任务编号 ${taskCode}。`), + return [ + singleSelectUpdate('Status', source.projectStatus), + singleSelectUpdate('Group', source.group), + singleSelectUpdate('Priority', source.priority), + singleSelectUpdate('Batch', source.batch), + singleSelectUpdate('Module', source.moduleName), + singleSelectUpdate('Risk', source.risk), + textUpdate('Dependency', source.dependency), + numberUpdate('ExpectedHours', source.expectedHours), + numberUpdate('ActualHours', source.actualHours), + textUpdate('OwnerNote', `自动同步自 issue #${source.issue.number};任务编号 ${source.taskCode}。`), ]; - - const labelsToAdd = new Set([groupLabelByGroup[group]].filter(Boolean)); - for (const module of modules) { + }; + const syncLabels = async (source) => { + const labelsToAdd = new Set([groupLabelByGroup[source.group]].filter(Boolean)); + for (const module of source.modules) { if (moduleLabelByModule[module]) labelsToAdd.add(moduleLabelByModule[module]); } const existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { @@ -229,55 +277,94 @@ jobs: await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, + issue_number: issueNumber, labels: validLabels, }); } - - const itemResult = await projectGraphql(itemQuery, { issueId: issue.node_id }); - let item = itemResult.node.projectItems.nodes.find((node) => node.project.id === project.id); - if (!item) { - const added = await projectGraphql(addItemMutation, { projectId: project.id, contentId: issue.node_id }); - item = added.addProjectV2ItemById.item; + }; + const writeSyncMarker = async (value, expectedFingerprint = '') => { + const markerIssue = await getIssue(); + const markerSource = expectedFingerprint + ? buildSyncSource(markerIssue) + : (isManagedIssue(markerIssue) ? { body: markerIssue.body || '' } : null); + if (!markerSource) return false; + if (expectedFingerprint && markerSource.fingerprint !== expectedFingerprint) { + throw new SourceChangedError(markerSource); } - for (const update of updatePlan) { - await projectGraphql(updateFieldMutation, { - projectId: project.id, - itemId: item.id, - fieldId: update.fieldId, - value: update.value, + const nextBody = replaceOrInsertField(markerSource.body, 'Project sync', value, ['GitHub Project']); + if (nextBody !== markerSource.body) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: nextBody, }); } - syncState = 'synced'; - } catch (error) { - syncErrorMessage = error.message; - core.warning(`Task issue sync failed: ${error.message}`); - } + if (expectedFingerprint) await assertSourceCurrent(expectedFingerprint); + return true; + }; - const latestResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - const latestIssue = latestResponse.data; - const latestBody = latestIssue.body || ''; - const stillManaged = !latestIssue.pull_request && - Boolean(parseTaskCode(latestIssue.title || '')) && - readFieldFromBody(latestBody, 'GitHub Project') === projectName; - if (!stillManaged) { - core.warning(`Issue #${issueNumber} stopped matching the managed-task contract before its body update.`); - } - const nextBody = stillManaged - ? replaceOrInsertField(latestBody, 'Project sync', syncState, ['GitHub Project']) - : latestBody; - if (nextBody !== latestBody) { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: nextBody, - }); + let syncSucceeded = false; + let syncErrorMessage = ''; + for (let attempt = 0; attempt < 3 && !syncSucceeded; attempt += 1) { + try { + const source = await readCurrentSource(); + if (!source) { + core.info('Issue does not look like a managed task issue; skipping.'); + return; + } + const projectResult = await projectGraphql(projectQuery, { login: projectOwner, number: projectNumber }); + const ownerNode = projectOwnerType === 'organization' ? projectResult.organization : projectResult.user; + const project = ownerNode?.projectV2; + if (!project) throw new Error(`Project '${projectName}' was not found for ${projectOwner}.`); + if (project.title !== projectName) { + throw new Error(`Project number ${projectNumber} resolved to '${project.title}', expected '${projectName}'.`); + } + const updatePlan = buildUpdatePlan(project, source); + const itemResult = await projectGraphql(itemQuery, { issueId: source.issue.node_id }); + let item = itemResult.node.projectItems.nodes.find((node) => node.project.id === project.id); + if (!item) { + await assertSourceCurrent(source.fingerprint); + const added = await projectGraphql(addItemMutation, { + projectId: project.id, + contentId: source.issue.node_id, + }); + await assertSourceCurrent(source.fingerprint); + item = added.addProjectV2ItemById.item; + } + for (const update of updatePlan) { + await assertSourceCurrent(source.fingerprint); + await projectGraphql(updateFieldMutation, { + projectId: project.id, + itemId: item.id, + fieldId: update.fieldId, + value: update.value, + }); + await assertSourceCurrent(source.fingerprint); + } + await assertSourceCurrent(source.fingerprint); + await syncLabels(source); + await assertSourceCurrent(source.fingerprint); + await writeSyncMarker('synced', source.fingerprint); + syncSucceeded = true; + } catch (error) { + if (error instanceof SourceChangedError) { + if (!error.source) { + core.warning(`Issue #${issueNumber} stopped matching the managed-task contract during sync.`); + return; + } + syncErrorMessage = error.message; + core.warning(`Task issue source changed; retrying Project sync (${attempt + 1}/3).`); + continue; + } + syncErrorMessage = error.message; + core.warning(`Task issue sync failed: ${error.message}`); + break; + } } - if (syncState === 'blocked') { - core.setFailed(`Task issue #${issue.number} sync blocked. ${syncErrorMessage}`); + + if (!syncSucceeded) { + if (!syncErrorMessage) syncErrorMessage = 'Task issue source did not stabilize after 3 attempts.'; + await writeSyncMarker('blocked'); + core.setFailed(`Task issue #${issueNumber} sync blocked. ${syncErrorMessage}`); } From 53aba70a3248768279dcd81c9d8f31b8a13ae291 Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 14:06:39 +0800 Subject: [PATCH 4/6] fix(workflows): move sync status out of issue body --- .github/tests/task-automation.test.cjs | 126 +++++++++++++++++++++---- .github/workflows/task-claim.yml | 6 +- .github/workflows/task-issue-sync.yml | 82 ++++++++++------ 3 files changed, 163 insertions(+), 51 deletions(-) diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs index 31d5b51..bf05715 100644 --- a/.github/tests/task-automation.test.cjs +++ b/.github/tests/task-automation.test.cjs @@ -337,10 +337,12 @@ const createSyncGithub = ({ title = 'Team Project', fields = projectFields(), onFieldUpdate, + initialComments = [], }) => { const usesIssueSequence = Array.isArray(latestIssue); const issueVersions = usesIssueSequence ? latestIssue : [latestIssue]; let currentIssue = clone(issueVersions[0]); + const comments = clone(initialComments); const calls = { get: [], graphql: [], @@ -348,6 +350,8 @@ const createSyncGithub = ({ fieldUpdates: [], addLabels: [], update: [], + createComment: [], + updateComment: [], }; const listLabelsForRepo = async () => [ { name: 'team:backend' }, @@ -364,11 +368,25 @@ const createSyncGithub = ({ return { data: clone(issue) }; }, listLabelsForRepo, + async listComments() { return clone(comments); }, async addLabels(args) { calls.addLabels.push(args); }, async update(args) { calls.update.push(args); if (!usesIssueSequence) currentIssue.body = args.body; }, + async createComment(args) { + calls.createComment.push(args); + comments.push({ + id: 8000 + comments.length, + body: args.body, + user: { login: 'github-actions[bot]' }, + }); + }, + async updateComment(args) { + calls.updateComment.push(args); + const comment = comments.find((candidate) => candidate.id === args.comment_id); + if (comment) comment.body = args.body; + }, }, }, async paginate(fn, args) { return fn(args); }, @@ -407,11 +425,18 @@ const createSyncGithub = ({ throw new Error(`Unexpected GraphQL operation: ${query}`); }, }; - return { github, calls }; + return { + github, + calls, + readIssue: () => clone(currentIssue), + readComments: () => clone(comments), + }; }; -const syncContext = (eventIssue) => ({ +const syncContext = (eventIssue, runId = 24680) => ({ eventName: 'issues', + runId, + serverUrl: 'https://github.com', repo: { owner: 'acme', repo: 'widgets' }, payload: { issue: eventIssue }, }); @@ -521,6 +546,7 @@ test('two interleaved claim events are both processed and converge on one winner const loser = logins.find((login) => login !== winner); assert.equal(winner, 'zara', 'the server-ordered first assignment wins, not login sort order'); assert.deepEqual(state.issue.assignees.map((assignee) => assignee.login), [winner]); + assert.match(state.issue.body, /- Project sync:`pending`/u); assert.equal(calls.get(winner).comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); assert.equal(calls.get(loser).comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); assert.equal(calls.get(loser).comments.some((comment) => comment.body.includes('认领失败')), true); @@ -777,6 +803,7 @@ test('actual hours only update a managed task and preserve the latest body', asy }); assert.equal(calls.update.length, 1); assert.match(calls.update[0].body, /- 实际工时(小时数):`2`/u); + assert.match(calls.update[0].body, /- Project sync:`pending`/u); }); await t.test('body is refreshed again immediately before the update', async () => { @@ -792,30 +819,36 @@ test('actual hours only update a managed task and preserve the latest body', asy assert.equal(calls.get.length, 2); assert.equal(calls.update.length, 1); assert.match(calls.update[0].body, /LATEST_WRITE_BASE/u); + assert.match(calls.update[0].body, /- Project sync:`pending`/u); assert.doesNotMatch(calls.update[0].body, /EVENT_ONLY/u); assert.doesNotMatch(calls.update[0].body, /FIRST_READ_ONLY/u); }); }); -test('task sync refreshes the body again before writing Project sync', async () => { - const firstRead = taskIssue({ body: taskBody({ extra: 'FIRST_READ_ONLY' }) }); - const writeBase = taskIssue({ body: taskBody({ extra: 'LATEST_WRITE_BASE' }) }); - const fields = projectFields().filter((field) => field.name !== 'OwnerNote'); - const { github, calls } = createSyncGithub({ - latestIssue: [firstRead, writeBase], - fields, +test('task sync never updates the issue body and preserves free text changed during the run', async () => { + assert.doesNotMatch( + readWorkflow('task-issue-sync.yml'), + /github\.rest\.issues\.update\s*\(/u, + 'Task Issue Sync must not PATCH an Issue body from a background synchronization run' + ); + const issue = taskIssue({ body: taskBody({ extra: 'ORIGINAL_FREE_TEXT' }) }); + const concurrentIssue = taskIssue({ body: taskBody({ extra: 'CONCURRENT_FREE_TEXT' }) }); + const { github, calls, readIssue } = createSyncGithub({ + latestIssue: issue, + onFieldUpdate: async ({ count, setIssue }) => { + if (count === 1) setIssue(concurrentIssue); + }, }); await runWorkflowScript('task-issue-sync.yml', { github, - context: syncContext(firstRead), + context: syncContext(issue), env: syncEnv, }); - assert.equal(calls.get.length, 2); - assert.equal(calls.update.length, 1); - assert.match(calls.update[0].body, /LATEST_WRITE_BASE/u); - assert.doesNotMatch(calls.update[0].body, /FIRST_READ_ONLY/u); + assert.equal(calls.update.length, 0); + assert.match(readIssue().body, /CONCURRENT_FREE_TEXT/u); + assert.doesNotMatch(readIssue().body, /ORIGINAL_FREE_TEXT/u); }); test('task sync always refreshes the issue before deciding whether it is managed', async () => { @@ -843,7 +876,7 @@ test('task sync retries a stale Project write and converges to the latest source '- 实际工时(小时数):`5`' ), }); - const { github, calls } = createSyncGithub({ + const { github, calls, readIssue } = createSyncGithub({ latestIssue: oldIssue, onFieldUpdate: async ({ count, setIssue }) => { if (count === 1) setIssue(newIssue); @@ -861,8 +894,62 @@ test('task sync retries a stale Project write and converges to the latest source ); assert.ok(calls.fieldUpdates.length > 10, 'the stale attempt must be followed by a full retry'); assert.equal(actualHoursWrites.at(-1).value.number, 5); - assert.match(calls.update.at(-1).body, /- 实际工时(小时数):`5`/u); - assert.match(calls.update.at(-1).body, /- Project sync:`synced`/u); + assert.equal(calls.update.length, 0); + assert.match(readIssue().body, /- 实际工时(小时数):`5`/u); +}); + +test('task sync reports repeated failures through one idempotent bot comment', async () => { + const issue = taskIssue(); + const { github, calls, readComments } = createSyncGithub({ + latestIssue: issue, + title: 'Different Project', + }); + + const firstRun = await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + const secondRun = await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue, 24681), + env: syncEnv, + }); + + assert.equal(firstRun.failures.length, 1); + assert.equal(secondRun.failures.length, 1); + assert.equal(calls.update.length, 0); + assert.equal(calls.createComment.length, 1); + assert.equal(calls.updateComment.length, 1); + assert.equal(readComments().length, 1); + assert.match(readComments()[0].body, //u); + assert.match(readComments()[0].body, /blocked/iu); + assert.match(readComments()[0].body, /Different Project/u); + assert.match(readComments()[0].body, /https:\/\/github\.com\/acme\/widgets\/actions\/runs\/24681/u); +}); + +test('task sync marks an existing blocked bot comment as recovered after success', async () => { + const issue = taskIssue(); + const { github, calls, readComments } = createSyncGithub({ + latestIssue: issue, + initialComments: [{ + id: 77, + body: '\nProject sync blocked.', + user: { login: 'github-actions[bot]' }, + }], + }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + + assert.equal(calls.update.length, 0); + assert.equal(calls.createComment.length, 0); + assert.equal(calls.updateComment.length, 1); + assert.match(readComments()[0].body, //u); + assert.match(readComments()[0].body, /恢复/u); }); test('task sync validates Project identity and the complete schema before mutations', async (t) => { @@ -945,8 +1032,9 @@ test('task sync mutates the Project after a complete valid schema passes', async assert.equal(calls.addLabels.length, 1); assert.equal(calls.projectMutations.filter((operation) => operation === 'add-item').length, 1); assert.equal(calls.projectMutations.filter((operation) => operation === 'update-field').length, 10); - assert.equal(calls.update.length, 1); - assert.match(calls.update[0].body, /- Project sync:`synced`/u); + assert.equal(calls.update.length, 0); + assert.equal(calls.createComment.length, 0); + assert.equal(calls.updateComment.length, 0); }); test('a PR is blocked when any closing issue is blocked', async () => { diff --git a/.github/workflows/task-claim.yml b/.github/workflows/task-claim.yml index de8cf76..cef895e 100644 --- a/.github/workflows/task-claim.yml +++ b/.github/workflows/task-claim.yml @@ -404,7 +404,7 @@ jobs: await createComment('实际工时更新失败:只有维护者、协作者或当前 Assignee 可以设置实际工时。'); return; } - let nextBody = replaceOrInsertField(body, '实际工时(小时数)', actualHours.text, ['预期工时(小时数)', '模块']); + const nextBody = replaceOrInsertField(body, '实际工时(小时数)', actualHours.text, ['预期工时(小时数)', '模块']); let syncState = 'blocked'; let syncErrorMessage = ''; try { @@ -413,14 +413,12 @@ jobs: syncErrorMessage = error.message; core.warning(`Project actual-hours sync failed: ${error.message}`); } - nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); const issueFieldsUpdated = await updateIssueFields([ { label: '实际工时(小时数)', value: actualHours.text, anchors: ['预期工时(小时数)', '模块'], }, - { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, ]); if (!issueFieldsUpdated) { core.setFailed(`Actual-hours update for issue #${issueNumber} was aborted because the task contract changed.`); @@ -557,7 +555,6 @@ jobs: syncErrorMessage = error.message; core.warning(`Project claim sync failed: ${error.message}`); } - nextBody = replaceOrInsertField(nextBody, 'Project sync', syncState, ['GitHub Project']); let issueFieldsUpdated = false; for (let attempt = 0; attempt < 3 && !issueFieldsUpdated; attempt += 1) { const finalControl = await waitForClaimControl(commenterLogin, claimTokenId, true); @@ -569,7 +566,6 @@ jobs: issueFieldsUpdated = await updateIssueFields([ { label: '状态', value: 'In Progress', anchors: ['编号'] }, { label: 'Risk', value: 'Normal', anchors: ['模块'] }, - { label: 'Project sync', value: syncState, anchors: ['GitHub Project'] }, ], (candidate) => { const eligibility = validateClaimIssue(candidate); const assignees = (candidate.assignees || []).map((assignee) => normalizeLogin(assignee.login)); diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index e6adf2c..ac16d81 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -122,16 +122,6 @@ jobs: } return { number, text: String(number) }; }; - const replaceOrInsertField = (issueBody, label, value, anchors = []) => { - const nextLine = `- ${label}:\`${value}\``; - const pattern = new RegExp(`^-\\s*${escapeRegExp(label)}\\s*[::].*$`, 'imu'); - if (pattern.test(issueBody)) return issueBody.replace(pattern, nextLine); - for (const anchor of anchors) { - const anchorPattern = new RegExp(`(^-\\s*${escapeRegExp(anchor)}\\s*[::].*$)`, 'imu'); - if (anchorPattern.test(issueBody)) return issueBody.replace(anchorPattern, (line) => `${line}\n${nextLine}`); - } - return `${issueBody.trimEnd()}\n${nextLine}\n`; - }; const isManagedIssue = (issue) => { const issueBody = issue.body || ''; return !issue.pull_request && @@ -184,7 +174,6 @@ jobs: }); return { issue, - body: issueBody, fingerprint, taskCode, group, @@ -282,26 +271,57 @@ jobs: }); } }; - const writeSyncMarker = async (value, expectedFingerprint = '') => { - const markerIssue = await getIssue(); - const markerSource = expectedFingerprint - ? buildSyncSource(markerIssue) - : (isManagedIssue(markerIssue) ? { body: markerIssue.body || '' } : null); - if (!markerSource) return false; - if (expectedFingerprint && markerSource.fingerprint !== expectedFingerprint) { - throw new SourceChangedError(markerSource); + const syncStatusMarker = ''; + const syncRunUrl = `${context.serverUrl || 'https://github.com'}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const findSyncStatusComment = async () => { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.find((candidate) => + candidate.user?.login === 'github-actions[bot]' && + String(candidate.body || '').includes(syncStatusMarker) + ); + }; + const updateSyncStatusComment = async (body, createIfMissing) => { + const existing = await findSyncStatusComment(); + if (existing) { + if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } + return; } - const nextBody = replaceOrInsertField(markerSource.body, 'Project sync', value, ['GitHub Project']); - if (nextBody !== markerSource.body) { - await github.rest.issues.update({ + if (createIfMissing) { + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, - body: nextBody, + body, }); } - if (expectedFingerprint) await assertSourceCurrent(expectedFingerprint); - return true; + }; + const reportSyncBlocked = async (message) => { + await updateSyncStatusComment( + `${syncStatusMarker}\nProject sync **blocked**。\n\n- 错误:${message}\n- Workflow run:${syncRunUrl}`, + true + ); + }; + const reportSyncRecovered = async () => { + const existing = await findSyncStatusComment(); + if (!existing || String(existing.body || '').includes('Project sync 已恢复')) return; + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: `${syncStatusMarker}\nProject sync 已恢复。\n\n- Workflow run:${syncRunUrl}`, + }); }; let syncSucceeded = false; @@ -345,8 +365,12 @@ jobs: await assertSourceCurrent(source.fingerprint); await syncLabels(source); await assertSourceCurrent(source.fingerprint); - await writeSyncMarker('synced', source.fingerprint); syncSucceeded = true; + try { + await reportSyncRecovered(); + } catch (error) { + core.warning(`Could not update the Project sync recovery comment: ${error.message}`); + } } catch (error) { if (error instanceof SourceChangedError) { if (!error.source) { @@ -365,6 +389,10 @@ jobs: if (!syncSucceeded) { if (!syncErrorMessage) syncErrorMessage = 'Task issue source did not stabilize after 3 attempts.'; - await writeSyncMarker('blocked'); + try { + await reportSyncBlocked(syncErrorMessage); + } catch (error) { + core.warning(`Could not publish the Project sync blocked comment: ${error.message}`); + } core.setFailed(`Task issue #${issueNumber} sync blocked. ${syncErrorMessage}`); } From 509c5edcaf0a314b49f8281c5a3ca17aa06e2adf Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 14:10:44 +0800 Subject: [PATCH 5/6] fix(workflows): use action runs for sync status --- .github/tests/task-automation.test.cjs | 76 +++++++++----------------- .github/workflows/task-issue-sync.yml | 63 --------------------- 2 files changed, 26 insertions(+), 113 deletions(-) diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs index bf05715..6e7a784 100644 --- a/.github/tests/task-automation.test.cjs +++ b/.github/tests/task-automation.test.cjs @@ -337,12 +337,10 @@ const createSyncGithub = ({ title = 'Team Project', fields = projectFields(), onFieldUpdate, - initialComments = [], }) => { const usesIssueSequence = Array.isArray(latestIssue); const issueVersions = usesIssueSequence ? latestIssue : [latestIssue]; let currentIssue = clone(issueVersions[0]); - const comments = clone(initialComments); const calls = { get: [], graphql: [], @@ -368,25 +366,11 @@ const createSyncGithub = ({ return { data: clone(issue) }; }, listLabelsForRepo, - async listComments() { return clone(comments); }, async addLabels(args) { calls.addLabels.push(args); }, async update(args) { calls.update.push(args); if (!usesIssueSequence) currentIssue.body = args.body; }, - async createComment(args) { - calls.createComment.push(args); - comments.push({ - id: 8000 + comments.length, - body: args.body, - user: { login: 'github-actions[bot]' }, - }); - }, - async updateComment(args) { - calls.updateComment.push(args); - const comment = comments.find((candidate) => candidate.id === args.comment_id); - if (comment) comment.body = args.body; - }, }, }, async paginate(fn, args) { return fn(args); }, @@ -429,14 +413,11 @@ const createSyncGithub = ({ github, calls, readIssue: () => clone(currentIssue), - readComments: () => clone(comments), }; }; -const syncContext = (eventIssue, runId = 24680) => ({ +const syncContext = (eventIssue) => ({ eventName: 'issues', - runId, - serverUrl: 'https://github.com', repo: { owner: 'acme', repo: 'widgets' }, payload: { issue: eventIssue }, }); @@ -828,8 +809,8 @@ test('actual hours only update a managed task and preserve the latest body', asy test('task sync never updates the issue body and preserves free text changed during the run', async () => { assert.doesNotMatch( readWorkflow('task-issue-sync.yml'), - /github\.rest\.issues\.update\s*\(/u, - 'Task Issue Sync must not PATCH an Issue body from a background synchronization run' + /task-project-sync-status|github\.rest\.issues\.(?:update|listComments|createComment|updateComment)\s*\(/u, + 'Task Issue Sync must not persist synchronization status on an Issue' ); const issue = taskIssue({ body: taskBody({ extra: 'ORIGINAL_FREE_TEXT' }) }); const concurrentIssue = taskIssue({ body: taskBody({ extra: 'CONCURRENT_FREE_TEXT' }) }); @@ -898,58 +879,53 @@ test('task sync retries a stale Project write and converges to the latest source assert.match(readIssue().body, /- 实际工时(小时数):`5`/u); }); -test('task sync reports repeated failures through one idempotent bot comment', async () => { +test('task sync reports configuration failures without writing the issue', async () => { const issue = taskIssue(); - const { github, calls, readComments } = createSyncGithub({ + const { github, calls } = createSyncGithub({ latestIssue: issue, title: 'Different Project', }); - const firstRun = await runWorkflowScript('task-issue-sync.yml', { + const result = await runWorkflowScript('task-issue-sync.yml', { github, context: syncContext(issue), env: syncEnv, }); - const secondRun = await runWorkflowScript('task-issue-sync.yml', { - github, - context: syncContext(issue, 24681), - env: syncEnv, - }); - assert.equal(firstRun.failures.length, 1); - assert.equal(secondRun.failures.length, 1); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /sync blocked/u); + assert.match(result.failures[0], /Different Project/u); assert.equal(calls.update.length, 0); - assert.equal(calls.createComment.length, 1); - assert.equal(calls.updateComment.length, 1); - assert.equal(readComments().length, 1); - assert.match(readComments()[0].body, //u); - assert.match(readComments()[0].body, /blocked/iu); - assert.match(readComments()[0].body, /Different Project/u); - assert.match(readComments()[0].body, /https:\/\/github\.com\/acme\/widgets\/actions\/runs\/24681/u); + assert.equal(calls.createComment.length, 0); + assert.equal(calls.updateComment.length, 0); }); -test('task sync marks an existing blocked bot comment as recovered after success', async () => { +test('task sync reports an unstable source without writing the issue', async () => { const issue = taskIssue(); - const { github, calls, readComments } = createSyncGithub({ + const { github, calls } = createSyncGithub({ latestIssue: issue, - initialComments: [{ - id: 77, - body: '\nProject sync blocked.', - user: { login: 'github-actions[bot]' }, - }], + onFieldUpdate: async ({ count, setIssue }) => { + setIssue(taskIssue({ + body: taskBody().replace( + '- 实际工时(小时数):`0`', + `- 实际工时(小时数):\`${count}\`` + ), + })); + }, }); - await runWorkflowScript('task-issue-sync.yml', { + const result = await runWorkflowScript('task-issue-sync.yml', { github, context: syncContext(issue), env: syncEnv, }); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /sync blocked/u); + assert.match(result.failures[0], /source changed/iu); assert.equal(calls.update.length, 0); assert.equal(calls.createComment.length, 0); - assert.equal(calls.updateComment.length, 1); - assert.match(readComments()[0].body, //u); - assert.match(readComments()[0].body, /恢复/u); + assert.equal(calls.updateComment.length, 0); }); test('task sync validates Project identity and the complete schema before mutations', async (t) => { diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index ac16d81..7a4a8c2 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -271,59 +271,6 @@ jobs: }); } }; - const syncStatusMarker = ''; - const syncRunUrl = `${context.serverUrl || 'https://github.com'}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const findSyncStatusComment = async () => { - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - return comments.find((candidate) => - candidate.user?.login === 'github-actions[bot]' && - String(candidate.body || '').includes(syncStatusMarker) - ); - }; - const updateSyncStatusComment = async (body, createIfMissing) => { - const existing = await findSyncStatusComment(); - if (existing) { - if (existing.body !== body) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } - return; - } - if (createIfMissing) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - } - }; - const reportSyncBlocked = async (message) => { - await updateSyncStatusComment( - `${syncStatusMarker}\nProject sync **blocked**。\n\n- 错误:${message}\n- Workflow run:${syncRunUrl}`, - true - ); - }; - const reportSyncRecovered = async () => { - const existing = await findSyncStatusComment(); - if (!existing || String(existing.body || '').includes('Project sync 已恢复')) return; - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: `${syncStatusMarker}\nProject sync 已恢复。\n\n- Workflow run:${syncRunUrl}`, - }); - }; - let syncSucceeded = false; let syncErrorMessage = ''; for (let attempt = 0; attempt < 3 && !syncSucceeded; attempt += 1) { @@ -366,11 +313,6 @@ jobs: await syncLabels(source); await assertSourceCurrent(source.fingerprint); syncSucceeded = true; - try { - await reportSyncRecovered(); - } catch (error) { - core.warning(`Could not update the Project sync recovery comment: ${error.message}`); - } } catch (error) { if (error instanceof SourceChangedError) { if (!error.source) { @@ -389,10 +331,5 @@ jobs: if (!syncSucceeded) { if (!syncErrorMessage) syncErrorMessage = 'Task issue source did not stabilize after 3 attempts.'; - try { - await reportSyncBlocked(syncErrorMessage); - } catch (error) { - core.warning(`Could not publish the Project sync blocked comment: ${error.message}`); - } core.setFailed(`Task issue #${issueNumber} sync blocked. ${syncErrorMessage}`); } From 9108551ddaa60f3cb83fd74b1eb573f48c56c57c Mon Sep 17 00:00:00 2001 From: JerryBlackoo <19166910919@163.com> Date: Fri, 10 Jul 2026 14:47:41 +0800 Subject: [PATCH 6/6] chore(repository): harden collaboration workflows --- .github/CODEOWNERS | 4 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/task_issue.md | 1 - .github/ISSUE_TEMPLATE/test_task_issue.md | 3 +- .github/dependabot.yml | 11 + .github/pull_request_template.md | 8 +- .github/tests/repository-hardening.test.cjs | 111 ++++++++ .github/tests/task-automation.test.cjs | 240 +++++++++++++++++- .github/workflows/auto-label.yml | 5 +- .github/workflows/codex-pr-review.yml | 76 ++++-- .github/workflows/commitlint.yml | 18 +- .github/workflows/docs-check.yml | 28 +- .github/workflows/pr-guard.yml | 39 ++- .github/workflows/task-claim.yml | 78 ++++-- .github/workflows/task-issue-sync.yml | 27 +- CONTRIBUTING.md | 6 +- README.md | 25 +- SECURITY.md | 9 + docs/README.md | 1 + docs/collaboration/overview.md | 4 +- docs/collaboration/repository-setup.md | 31 ++- .../task-issue-project-workflow.md | 11 +- docs/templates/project-kickoff-checklist.md | 9 +- 23 files changed, 626 insertions(+), 120 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/tests/repository-hardening.test.cjs create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fddb7bb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +* @JerryBlackoo +/.github/workflows/ @JerryBlackoo +/.github/codex/ @JerryBlackoo +/docs/collaboration/ @JerryBlackoo diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/task_issue.md b/.github/ISSUE_TEMPLATE/task_issue.md index aa423ca..f75441f 100644 --- a/.github/ISSUE_TEMPLATE/task_issue.md +++ b/.github/ISSUE_TEMPLATE/task_issue.md @@ -30,7 +30,6 @@ assignees: '' - 依赖原因:写清楚依赖的接口、schema、数据结构、环境变量、服务能力或验收条件。 - 建议分支:`Team/type/short-title` - GitHub Project:`Team Project` -- Project sync:`pending / synced / blocked` ## 发布前检查 diff --git a/.github/ISSUE_TEMPLATE/test_task_issue.md b/.github/ISSUE_TEMPLATE/test_task_issue.md index 79b19cc..a890246 100644 --- a/.github/ISSUE_TEMPLATE/test_task_issue.md +++ b/.github/ISSUE_TEMPLATE/test_task_issue.md @@ -2,7 +2,7 @@ name: Test Task Issue about: Create a tracked task for testing work and test execution follow-up title: '[Q-001] 中文测试任务标题' -labels: 'testing' +labels: 'area:testing' assignees: '' --- @@ -29,7 +29,6 @@ assignees: '' - 依赖原因:写清楚依赖的接口、schema、数据结构、环境变量、服务能力或验收条件。 - 建议分支:`QA/test/short-title` - GitHub Project:`Team Project` -- Project sync:`pending / synced / blocked` ## 发布前检查 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fd2ae57 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + target-branch: develop + schedule: + interval: weekly + commit-message: + prefix: "chore(deps)" + labels: + - ci diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ad860e6..fc08035 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,13 @@ ## 验证 -请用中文说明已经运行的检查,例如 lint、type-check、test、build、人工验收。 +已运行: + +- ``:<结果> + +未运行: + +- 无。或填写 ``:原因:<原因>;残余风险:<风险>。 ## 已知风险 diff --git a/.github/tests/repository-hardening.test.cjs b/.github/tests/repository-hardening.test.cjs new file mode 100644 index 0000000..792fb98 --- /dev/null +++ b/.github/tests/repository-hardening.test.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'); + +test('all GitHub Actions dependencies are pinned to full commit SHAs', () => { + const workflowDir = path.join(root, '.github', 'workflows'); + for (const name of fs.readdirSync(workflowDir).filter((entry) => entry.endsWith('.yml'))) { + const source = fs.readFileSync(path.join(workflowDir, name), 'utf8'); + for (const match of source.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gmu)) { + assert.match(match[1], /@[0-9a-f]{40}$/u, `${name}: ${match[1]} must be pinned`); + } + } +}); + +test('Codex review is opt-in, trusted-only, read-only, and publishes from a separate job', () => { + const source = read('.github/workflows/codex-pr-review.yml'); + assert.match(source, /vars\.CODEX_REVIEW_ENABLED\s*==\s*'true'/u); + assert.doesNotMatch(source, /allow-users:\s*["']?\*["']?/u); + assert.match(source, /permission-profile:\s*["']?:read-only["']?/u); + assert.match(source, /\n\s{2}publish:\s*\n/u); + assert.match(source, /needs:\s*review/u); +}); + +test('Docs Check validates the event commit range instead of the clean worktree', () => { + const source = read('.github/workflows/docs-check.yml'); + assert.match(source, /github\.event\.pull_request\.base\.sha/u); + assert.match(source, /github\.event\.before/u); + assert.doesNotMatch(source, /run:\s*git diff --check\s*$/mu); +}); + +test('PR Guard validates Conventional Commit PR titles', () => { + const source = read('.github/workflows/pr-guard.yml'); + assert.match(source, /conventionalTitlePattern/u); + assert.match(source, /PR title must follow Conventional Commits/u); +}); + +test('Dependabot targets develop and is explicitly recognized as trusted automation', () => { + const dependabot = read('.github/dependabot.yml'); + assert.match(dependabot, /^\s*target-branch:\s*develop$/mu); + assert.match(dependabot, /^\s*prefix:\s*["']chore\(deps\)["']$/mu); + for (const workflow of ['pr-guard.yml', 'commitlint.yml']) { + const source = read(`.github/workflows/${workflow}`); + assert.match(source, /TRUSTED_AUTOMATION_USERS/u); + assert.match(source, /dependabot\[bot\]/u); + } +}); + +test('required workflow jobs expose the documented check names', () => { + const checks = new Map([ + ['pr-guard.yml', 'PR Guard'], + ['commitlint.yml', 'Commitlint'], + ['docs-check.yml', 'Docs Check'], + ]); + for (const [workflow, checkName] of checks) { + const source = read(`.github/workflows/${workflow}`); + assert.match(source, new RegExp(`^\\s{4}name:\\s*${checkName}$`, 'mu')); + } +}); + +test('required read-only checks bind to the pull request commit', () => { + for (const workflow of ['pr-guard.yml', 'commitlint.yml']) { + const source = read(`.github/workflows/${workflow}`); + assert.match(source, /^\s{2}pull_request:\s*$/mu); + assert.doesNotMatch(source, /^\s{2}pull_request_target:\s*$/mu); + } + assert.match(read('.github/workflows/auto-label.yml'), /^\s{2}pull_request_target:\s*$/mu); +}); + +test('security reporting instructions do not point copied repositories to the template source', () => { + const security = read('SECURITY.md'); + const issueConfig = read('.github/ISSUE_TEMPLATE/config.yml'); + assert.doesNotMatch(security, /JerryBlackoo\/github-teamwork/u); + assert.doesNotMatch(issueConfig, /JerryBlackoo\/github-teamwork/u); + assert.match(security, /Private vulnerability reporting/u); +}); + +test('issue templates use configured labels and do not expose obsolete sync state', () => { + const task = read('.github/ISSUE_TEMPLATE/task_issue.md'); + const testTask = read('.github/ISSUE_TEMPLATE/test_task_issue.md'); + assert.doesNotMatch(task, /Project sync/u); + assert.doesNotMatch(testTask, /Project sync/u); + assert.match(testTask, /^labels:\s*'area:testing'$/mu); + const config = read('.github/ISSUE_TEMPLATE/config.yml'); + assert.match(config, /^blank_issues_enabled:\s*false$/mu); +}); + +test('local Markdown links resolve to repository files', () => { + const markdownFiles = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === '.git') continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.name.endsWith('.md')) markdownFiles.push(absolute); + } + }; + visit(root); + for (const file of markdownFiles) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/\[[^\]]+\]\(([^)]+)\)/gu)) { + const target = match[1].split('#', 1)[0]; + if (!target || /^(?:https?:|mailto:)/u.test(target)) continue; + const resolved = path.resolve(path.dirname(file), decodeURIComponent(target)); + assert.ok(fs.existsSync(resolved), `${path.relative(root, file)} links to missing ${target}`); + } + } +}); diff --git a/.github/tests/task-automation.test.cjs b/.github/tests/task-automation.test.cjs index 6e7a784..7cbf855 100644 --- a/.github/tests/task-automation.test.cjs +++ b/.github/tests/task-automation.test.cjs @@ -75,6 +75,7 @@ const taskIssue = (overrides = {}) => ({ title: '[B-001] Implement API', body: taskBody(), state: 'open', + author_association: 'MEMBER', assignees: [], labels: [], ...overrides, @@ -101,8 +102,63 @@ const claimContext = ( }, }); +const prGuardContext = (verification) => ({ + repo: { owner: 'acme', repo: 'widgets' }, + payload: { + pull_request: { + title: 'fix(workflows): reject template placeholders', + body: [ + '## 修改内容', + '', + '- 修复 PR 验证信息校验。', + '', + '## 关联 Issue', + '', + '- 无。原因:仓库规则加固,不对应独立任务。', + '', + '## 验证', + '', + verification, + '', + '## 已知风险', + '', + '- 无。', + ].join('\n'), + base: { + ref: 'develop', + repo: { full_name: 'acme/widgets' }, + }, + head: { + ref: 'fix/pr-guard', + repo: { + fork: true, + full_name: 'alice/widgets', + owner: { login: 'alice' }, + }, + }, + user: { login: 'alice' }, + }, + }, +}); + +const prGuardGithub = { + rest: { + repos: { + async compareCommitsWithBasehead() { + return { data: { status: 'ahead' } }; + }, + }, + }, +}; + +const prGuardEnv = { + ALLOWED_BASE_BRANCHES: 'develop', + OWNER_DIRECT_BRANCH_USERS: 'acme', +}; + const createClaimGithub = (latestIssue) => { const issueVersions = Array.isArray(latestIssue) ? latestIssue : [latestIssue]; + let currentIssue = clone(issueVersions[0]); const calls = { get: [], addAssignees: [], @@ -115,11 +171,17 @@ const createClaimGithub = (latestIssue) => { issues: { async get(args) { calls.get.push(args); - return { data: issueVersions[Math.min(calls.get.length - 1, issueVersions.length - 1)] }; + const versionIndex = calls.get.length - 1; + if (versionIndex < issueVersions.length) currentIssue = clone(issueVersions[versionIndex]); + return { data: clone(currentIssue) }; }, async addAssignees(args) { calls.addAssignees.push(args); }, async removeAssignees(args) { calls.removeAssignees.push(args); }, - async update(args) { calls.update.push(args); }, + async update(args) { + calls.update.push(args); + currentIssue.body = args.body; + return { data: clone(currentIssue) }; + }, async createComment(args) { calls.comments.push(args); }, }, }, @@ -337,6 +399,10 @@ const createSyncGithub = ({ title = 'Team Project', fields = projectFields(), onFieldUpdate, + repositoryLabels = [ + { name: 'team:backend' }, + { name: 'area:docs' }, + ], }) => { const usesIssueSequence = Array.isArray(latestIssue); const issueVersions = usesIssueSequence ? latestIssue : [latestIssue]; @@ -347,14 +413,12 @@ const createSyncGithub = ({ projectMutations: [], fieldUpdates: [], addLabels: [], + removeLabel: [], update: [], createComment: [], updateComment: [], }; - const listLabelsForRepo = async () => [ - { name: 'team:backend' }, - { name: 'area:docs' }, - ]; + const listLabelsForRepo = async () => repositoryLabels; const github = { rest: { issues: { @@ -367,6 +431,7 @@ const createSyncGithub = ({ }, listLabelsForRepo, async addLabels(args) { calls.addLabels.push(args); }, + async removeLabel(args) { calls.removeLabel.push(args); }, async update(args) { calls.update.push(args); if (!usesIssueSequence) currentIssue.body = args.body; @@ -570,7 +635,7 @@ test('a delayed contender cannot displace a winner with an earlier assignment ev assert.deepEqual(calls.get('alice').removeAssignees[0].assignees, ['alice']); }); -test('two claim comments for the same login bind one assignment epoch to one comment token', async () => { +test('two claim comments for the same login bind the assignment to the latest valid token', async () => { const runnerKeys = ['first', 'second']; const issue = taskIssue(); const { state, calls, githubFor } = createClaimHarness({ @@ -593,10 +658,12 @@ test('two claim comments for the same login bind one assignment epoch to one com }))); assert.deepEqual(state.issue.assignees.map((assignee) => assignee.login), ['alice']); - assert.equal(calls.get('first').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); - assert.equal(calls.get('second').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); + assert.equal(calls.get('first').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 0); + assert.equal(calls.get('second').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get('first').removeAssignees.length, 0); assert.equal(calls.get('second').removeAssignees.length, 0); - assert.equal(calls.get('second').projectUpdates.length, 0); + assert.equal(calls.get('first').projectUpdates.length, 0); + assert.equal(calls.get('second').projectUpdates.length, 10); }); test('a new comment owns a reassigned login after the previous assignment epoch ends', async () => { @@ -636,6 +703,38 @@ test('a new comment owns a reassigned login after the previous assignment epoch assert.equal(calls.get('alice').removeAssignees.length, 0); }); +test('a new claim comment supersedes an old comment that never created an assignment', async () => { + const issue = taskIssue(); + const comments = [ + { + id: 900, + body: '认领:@alice', + user: { login: 'alice' }, + created_at: '2026-07-09T01:00:00Z', + }, + { + id: 1000, + body: '认领:@alice', + user: { login: 'alice' }, + created_at: '2026-07-10T01:00:00Z', + }, + ]; + const { state, calls, githubFor } = createClaimHarness({ + initialIssue: issue, + initialComments: comments, + }); + + await runWorkflowScript('task-claim.yml', { + github: githubFor('alice'), + context: claimContext(issue, '认领:@alice', 'alice', 1000, '2026-07-10T01:00:00Z'), + env: { ...syncEnv, CLAIM_SETTLE_DELAY_MS: '0' }, + }); + + assert.match(state.issue.body, /- 状态:`In Progress`/u); + assert.equal(calls.get('alice').comments.filter((comment) => comment.body.includes('已认领本任务')).length, 1); + assert.equal(calls.get('alice').removeAssignees.length, 0); +}); + test('claim aborts and rolls back itself when the issue closes after assignment', async () => { const issue = taskIssue(); const { state, calls, githubFor } = createClaimHarness({ @@ -797,7 +896,7 @@ test('actual hours only update a managed task and preserve the latest body', asy context: claimContext(eventIssue, '实际工时:2'), env: syncEnv, }); - assert.equal(calls.get.length, 2); + assert.equal(calls.get.length, 4); assert.equal(calls.update.length, 1); assert.match(calls.update[0].body, /LATEST_WRITE_BASE/u); assert.match(calls.update[0].body, /- Project sync:`pending`/u); @@ -1013,6 +1112,50 @@ test('task sync mutates the Project after a complete valid schema passes', async assert.equal(calls.updateComment.length, 0); }); +test('task sync ignores managed-looking issues from untrusted authors', async () => { + const issue = taskIssue({ author_association: 'NONE' }); + const { github, calls } = createSyncGithub({ latestIssue: issue }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + + assert.deepEqual(calls.projectMutations, []); + assert.deepEqual(calls.addLabels, []); + assert.deepEqual(calls.removeLabel, []); +}); + +test('task sync replaces stale managed labels and preserves unrelated labels', async () => { + const issue = taskIssue({ + labels: [ + { name: 'team:frontend' }, + { name: 'area:frontend' }, + { name: 'security' }, + ], + }); + const { github, calls } = createSyncGithub({ + latestIssue: issue, + repositoryLabels: [ + { name: 'team:backend' }, + { name: 'team:frontend' }, + { name: 'area:docs' }, + { name: 'area:frontend' }, + { name: 'security' }, + ], + }); + + await runWorkflowScript('task-issue-sync.yml', { + github, + context: syncContext(issue), + env: syncEnv, + }); + + assert.deepEqual(calls.addLabels[0].labels.sort(), ['area:docs', 'team:backend']); + assert.deepEqual(calls.removeLabel.map((call) => call.name).sort(), ['area:frontend', 'team:frontend']); +}); + test('a PR is blocked when any closing issue is blocked', async () => { const pr = { number: 42, labels: [] }; const linkedIssues = new Map([ @@ -1052,3 +1195,78 @@ test('a closing-issue lookup error preserves an existing blocked label', async ( assert.equal(calls.removeLabel.length, 0); assert.ok(result.warnings.some((message) => message.includes('#8'))); }); + +test('PR Guard rejects untouched verification placeholders', async () => { + const verification = [ + '已运行:', + '', + '- ``:<结果>', + '', + '未运行:', + '', + '- 无。或填写 ``:原因:<原因>;残余风险:<风险>。', + ].join('\n'); + + const result = await runWorkflowScript('pr-guard.yml', { + github: prGuardGithub, + context: prGuardContext(verification), + env: prGuardEnv, + }); + + assert.equal(result.failures.length, 1); + assert.match(result.failures[0], /template placeholder text/u); +}); + +test('PR Guard accepts completed verification evidence', async () => { + const verification = [ + '已运行:', + '', + '- `node --test .github/tests/*.test.cjs`:通过。', + '', + '未运行:', + '', + '- `actionlint`:原因:本地未安装;残余风险:Actions schema 由 CI 检查。', + ].join('\n'); + + const result = await runWorkflowScript('pr-guard.yml', { + github: prGuardGithub, + context: prGuardContext(verification), + env: prGuardEnv, + }); + + assert.deepEqual(result.failures, []); +}); + +test('PR Guard accepts trusted Dependabot pull requests without human template sections', async () => { + const context = prGuardContext('unused'); + const pr = context.payload.pull_request; + pr.title = 'Bump actions/checkout from 6 to 7'; + pr.body = 'Bumps actions/checkout from 6 to 7.'; + pr.user.login = 'dependabot[bot]'; + pr.head.repo = { + fork: false, + full_name: 'acme/widgets', + owner: { login: 'acme' }, + }; + + const result = await runWorkflowScript('pr-guard.yml', { + github: prGuardGithub, + context, + env: { ...prGuardEnv, TRUSTED_AUTOMATION_USERS: 'dependabot[bot]' }, + }); + + assert.deepEqual(result.failures, []); +}); + +test('Commitlint delegates generated messages from trusted automation', async () => { + const result = await runWorkflowScript('commitlint.yml', { + github: {}, + context: { + repo: { owner: 'acme', repo: 'widgets' }, + payload: { pull_request: { number: 42, user: { login: 'dependabot[bot]' } } }, + }, + env: { TRUSTED_AUTOMATION_USERS: 'dependabot[bot]' }, + }); + + assert.deepEqual(result.failures, []); +}); diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 65fb3a9..b3a9c1f 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -13,10 +13,11 @@ permissions: jobs: label: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Apply configured labels and blocked state - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const configPath = '.github/labeler.json'; diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 00ef9ac..e365925 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -5,27 +5,33 @@ on: branches: [develop] types: [opened, synchronize, reopened, ready_for_review] -permissions: - contents: read - pull-requests: write - issues: write +permissions: {} + +concurrency: + group: codex-review-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: review: name: Review pull request - if: ${{ github.event.pull_request.draft == false }} - runs-on: ubuntu-22.04 + if: ${{ github.event.pull_request.draft == false && vars.CODEX_REVIEW_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + outputs: + final_message: ${{ steps.codex.outputs.final-message }} steps: - name: Check out base branch - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Build PR review context - id: pr_context - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: CONTEXT_PATH: .github/codex/pr-context.md PR_HEAD_PATH: .github/codex/pr-head @@ -34,12 +40,15 @@ jobs: const fs = require('fs'); const path = require('path'); const pr = context.payload.pull_request; + if (!pr.head.repo) throw new Error('PR head repository is unavailable.'); + const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100, }); + if (files.length > 500) throw new Error(`PR changes ${files.length} files; review limit is 500.`); const commits = await github.paginate(github.rest.pulls.listCommits, { owner: context.repo.owner, repo: context.repo.repo, @@ -50,6 +59,9 @@ jobs: const prHeadRoot = path.resolve(process.env.PR_HEAD_PATH); const [headOwner, headRepo] = pr.head.repo.full_name.split('/'); const materializedHeadFiles = new Set(); + const maxFileBytes = 1_000_000; + const maxTotalBytes = 20_000_000; + let materializedBytes = 0; fs.mkdirSync(prHeadRoot, { recursive: true }); const safeHeadFilePath = (filename) => { @@ -63,10 +75,16 @@ jobs: for (const file of files) { if (file.status === 'removed') continue; try { - const targetPath = safeHeadFilePath(file.filename); const blob = await github.rest.git.getBlob({ owner: headOwner, repo: headRepo, file_sha: file.sha }); + const size = Number(blob.data.size || 0); + if (size > maxFileBytes || materializedBytes + size > maxTotalBytes) { + core.warning(`Skipping full content for ${file.filename}: materialization limit reached.`); + continue; + } + const targetPath = safeHeadFilePath(file.filename); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, Buffer.from(blob.data.content, blob.data.encoding === 'base64' ? 'base64' : 'utf8')); + materializedBytes += size; materializedHeadFiles.add(file.filename); } catch (error) { core.warning(`Could not materialize ${file.filename}: ${error.message}`); @@ -82,7 +100,7 @@ jobs: append(`Title: ${pr.title}`); append(`Author: ${pr.user?.login || 'unknown'}`); append(`Base: ${pr.base.ref} (${pr.base.sha})`); - append(`Head: ${pr.head.repo?.full_name || 'unknown'}:${pr.head.ref} (${pr.head.sha})`); + append(`Head: ${pr.head.repo.full_name}:${pr.head.ref} (${pr.head.sha})`); append(); append('## Pull Request Body'); append(); @@ -125,45 +143,49 @@ jobs: } fs.mkdirSync(path.dirname(process.env.CONTEXT_PATH), { recursive: true }); fs.writeFileSync(process.env.CONTEXT_PATH, `${lines.join('\n')}\n`, 'utf8'); - core.setOutput('pull-request-number', String(pr.number)); - name: Run Codex review id: codex - uses: openai/codex-action@v1 + uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1.11 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} responses-api-endpoint: ${{ vars.OPENAI_RESPONSES_API_ENDPOINT }} prompt-file: .github/codex/prompts/review.md - output-file: .github/codex/review-output.md - allow-users: "*" - sandbox: workspace-write + permission-profile: ":read-only" + publish: + name: Publish review comment + needs: review + if: ${{ needs.review.outputs.final_message != '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + pull-requests: write + steps: - name: Publish PR review comment - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - REVIEW_PATH: .github/codex/review-output.md + REVIEW: ${{ needs.review.outputs.final_message }} with: script: | - const fs = require('fs'); - const pullNumber = Number('${{ steps.pr_context.outputs.pull-request-number }}'); - const review = fs.existsSync(process.env.REVIEW_PATH) - ? fs.readFileSync(process.env.REVIEW_PATH, 'utf8').trim() - : ''; - const headSha = context.payload.pull_request?.head?.sha || 'unknown'; + const review = String(process.env.REVIEW || '').trim(); + if (!review) return; + const pr = context.payload.pull_request; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const body = [ '## Codex PR Review', '', - review || 'Codex did not return a review body.', + review, '', '---', - `Head: \`${headSha.slice(0, 12)}\` | [Workflow run](${runUrl})`, + `Head: \`${pr.head.sha.slice(0, 12)}\` | [Workflow run](${runUrl})`, '', '_Generated by GitHub Actions using openai/codex-action._', ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: pullNumber, + issue_number: pr.number, body, }); diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 2cdab32..73715c2 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,7 +1,7 @@ name: Commitlint on: - pull_request_target: + pull_request: types: [opened, edited, synchronize, reopened, ready_for_review] permissions: @@ -9,13 +9,25 @@ permissions: jobs: commitlint: - runs-on: ubuntu-latest + name: Commitlint + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - name: Validate commit messages - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + TRUSTED_AUTOMATION_USERS: dependabot[bot] with: script: | const pr = context.payload.pull_request; + const trustedAutomationUsers = (process.env.TRUSTED_AUTOMATION_USERS || '') + .split(',') + .map((login) => login.trim()) + .filter(Boolean); + if (trustedAutomationUsers.includes(pr.user.login)) { + core.info(`Commit message validation is delegated for trusted automation @${pr.user.login}.`); + return; + } const allowedTypes = new Set([ 'feat', 'fix', diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 4ea9692..97c5c56 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -3,26 +3,23 @@ name: Docs Check on: pull_request: branches: [develop] - paths: - - 'docs/**' - - '*.md' - - '.github/**' push: branches: [develop] - paths: - - 'docs/**' - - '*.md' - - '.github/**' permissions: contents: read jobs: check: - runs-on: ubuntu-latest + name: Docs Check + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -45,4 +42,13 @@ jobs: test -f docs/testing/templates/test-report-template.md - name: Check whitespace - run: git diff --check + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if [[ "$EVENT_NAME" == "push" && "$BASE_SHA" =~ ^0+$ ]]; then + git show --check --format= "$HEAD_SHA" + else + git diff --check "$BASE_SHA...$HEAD_SHA" + fi diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml index 1291644..9f03744 100644 --- a/.github/workflows/pr-guard.yml +++ b/.github/workflows/pr-guard.yml @@ -1,7 +1,7 @@ name: PR Guard on: - pull_request_target: + pull_request: types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled] permissions: @@ -10,13 +10,16 @@ permissions: jobs: guard: - runs-on: ubuntu-latest + name: PR Guard + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - name: Enforce collaboration rules - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ALLOWED_BASE_BRANCHES: develop OWNER_DIRECT_BRANCH_USERS: ${{ github.repository_owner }} + TRUSTED_AUTOMATION_USERS: dependabot[bot] with: script: | const pr = context.payload.pull_request; @@ -28,6 +31,11 @@ jobs: .split(',') .map((login) => login.trim()) .filter(Boolean); + const trustedAutomationUsers = (process.env.TRUSTED_AUTOMATION_USERS || '') + .split(',') + .map((login) => login.trim()) + .filter(Boolean); + const isTrustedAutomation = trustedAutomationUsers.includes(pr.user.login); const failures = []; if (!allowedBaseBranches.includes(pr.base.ref)) { @@ -40,7 +48,7 @@ jobs: const isForkPr = pr.head.repo.fork; const isSameRepoPr = pr.head.repo.full_name === pr.base.repo.full_name; const isOwnerDirectBranchPr = - isSameRepoPr && ownerDirectBranchUsers.includes(pr.user.login); + isSameRepoPr && (ownerDirectBranchUsers.includes(pr.user.login) || isTrustedAutomation); if (!isForkPr && !isOwnerDirectBranchPr) { failures.push( @@ -74,9 +82,10 @@ jobs: const body = pr.body || ''; const hasCjk = /[\u3400-\u9fff]/u.test(body); const hasCjkTitle = /[\u3400-\u9fff]/u.test(pr.title || ''); + const conventionalTitlePattern = /^(feat|fix|docs|style|refactor|test|chore|perf|revert)(?:\([a-z0-9._-]+\))?!?: (.+)$/u; const closingKeywordPattern = /\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+\b/iu; const noIssueReasonPattern = /^无。原因[::]\s*\S+/u; - const placeholderPattern = /(请用中文|请保留其中一种||<说明|已运行必要检查)/u; + const placeholderPattern = /(请用中文|请保留其中一种||<说明||<结果>|<原因>|<风险>|已运行必要检查)/u; const sectionValue = (heading) => { const pattern = new RegExp(`##\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s+|$)`, 'u'); @@ -84,15 +93,24 @@ jobs: return match ? match[1].trim() : ''; }; - if (hasCjkTitle) { + if (!isTrustedAutomation && hasCjkTitle) { failures.push('PR title must use English and must not contain Chinese characters.'); } + const titleMatch = String(pr.title || '').match(conventionalTitlePattern); + if (!isTrustedAutomation && ( + !titleMatch || + /^[A-Z]/u.test(titleMatch[2]) || + titleMatch[2].endsWith('.') || + pr.title.length > 72 + )) { + failures.push('PR title must follow Conventional Commits, use a lowercase subject, omit the final period, and stay within 72 characters.'); + } - if (!hasCjk) { + if (!isTrustedAutomation && !hasCjk) { failures.push('PR description must be written in Chinese.'); } - for (const heading of ['修改内容', '关联 Issue', '验证', '已知风险']) { + for (const heading of isTrustedAutomation ? [] : ['修改内容', '关联 Issue', '验证', '已知风险']) { const value = sectionValue(heading); if (!value) { failures.push(`PR description is missing the '${heading}' section content.`); @@ -104,8 +122,13 @@ jobs: } const issueSection = sectionValue('关联 Issue'); + const verificationSection = sectionValue('验证'); + if (!isTrustedAutomation && verificationSection && (!/已运行\s*[::]/u.test(verificationSection) || !/未运行\s*[::]/u.test(verificationSection))) { + failures.push("'验证' must explicitly list both '已运行:' and '未运行:'."); + } const normalizedIssueSection = issueSection.replace(/^[-*]\s*/u, '').trim(); if ( + !isTrustedAutomation && issueSection && !closingKeywordPattern.test(issueSection) && !noIssueReasonPattern.test(normalizedIssueSection) diff --git a/.github/workflows/task-claim.yml b/.github/workflows/task-claim.yml index cef895e..676e631 100644 --- a/.github/workflows/task-claim.yml +++ b/.github/workflows/task-claim.yml @@ -11,10 +11,11 @@ permissions: jobs: claim: if: ${{ !github.event.issue.pull_request }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Handle task comment - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PROJECTS_TOKEN: ${{ secrets.PROJECTS_TOKEN }} PROJECT_OWNER: ${{ vars.PROJECT_OWNER }} @@ -182,10 +183,11 @@ jobs: ? Date.parse(lastUnassigned.created_at || 0) : Number.NEGATIVE_INFINITY; const upperBound = Date.parse(assignment.created_at || 0); - const token = orderedClaimComments.find((candidate) => { + const tokens = orderedClaimComments.filter((candidate) => { const createdAt = Date.parse(candidate.created_at || 0); return candidate.claimLogin === login && createdAt > lowerBound && createdAt <= upperBound; }); + const token = tokens[tokens.length - 1]; assignment.claimTokenId = token ? String(token.id) : ''; } const assigneeLogins = (latestIssue.assignees || []).map((assignee) => normalizeLogin(assignee.login)); @@ -404,15 +406,6 @@ jobs: await createComment('实际工时更新失败:只有维护者、协作者或当前 Assignee 可以设置实际工时。'); return; } - const nextBody = replaceOrInsertField(body, '实际工时(小时数)', actualHours.text, ['预期工时(小时数)', '模块']); - let syncState = 'blocked'; - let syncErrorMessage = ''; - try { - syncState = await syncProject(issue, nextBody, 'hours', `@${comment.user.login} 通过 issue 评论更新实际工时。`); - } catch (error) { - syncErrorMessage = error.message; - core.warning(`Project actual-hours sync failed: ${error.message}`); - } const issueFieldsUpdated = await updateIssueFields([ { label: '实际工时(小时数)', @@ -424,6 +417,31 @@ jobs: core.setFailed(`Actual-hours update for issue #${issueNumber} was aborted because the task contract changed.`); return; } + const committedIssue = await getIssue(); + const committedHours = readFieldFromBody(committedIssue.body || '', '实际工时(小时数)'); + if (!isManagedTask(committedIssue) || committedHours !== actualHours.text) { + core.setFailed(`Actual-hours update for issue #${issueNumber} did not remain committed.`); + return; + } + let syncState = 'blocked'; + let syncErrorMessage = ''; + try { + syncState = await syncProject( + committedIssue, + committedIssue.body || '', + 'hours', + `@${comment.user.login} 通过 issue 评论更新实际工时。` + ); + } catch (error) { + syncErrorMessage = error.message; + core.warning(`Project actual-hours sync failed: ${error.message}`); + } + const finalIssue = await getIssue(); + const finalHours = readFieldFromBody(finalIssue.body || '', '实际工时(小时数)'); + if (!isManagedTask(finalIssue) || finalHours !== actualHours.text) { + core.setFailed(`Actual-hours update for issue #${issueNumber} changed during Project sync.`); + return; + } if (syncState === 'blocked') { core.setFailed(`Updated actual hours on issue #${issue.number}, but Project sync is blocked. ${syncErrorMessage}`); } else { @@ -543,18 +561,6 @@ jobs: if (await rejectAfterAssignment(claimControl, canPromoteFrom(claimControl))) return; } - const claimIssue = claimControl.snapshot.issue; - const claimBody = claimIssue.body || ''; - let nextBody = replaceOrInsertField(claimBody, '状态', 'In Progress', ['编号']); - nextBody = replaceOrInsertField(nextBody, 'Risk', 'Normal', ['模块']); - let syncState = 'blocked'; - let syncErrorMessage = ''; - try { - syncState = await syncProject(claimIssue, nextBody, 'claim', `@${commenterLogin} 通过 issue 评论认领本任务。`); - } catch (error) { - syncErrorMessage = error.message; - core.warning(`Project claim sync failed: ${error.message}`); - } let issueFieldsUpdated = false; for (let attempt = 0; attempt < 3 && !issueFieldsUpdated; attempt += 1) { const finalControl = await waitForClaimControl(commenterLogin, claimTokenId, true); @@ -589,6 +595,30 @@ jobs: }, canPromoteFrom(committedControl)); return; } + let syncState = 'blocked'; + let syncErrorMessage = ''; + try { + const committedIssue = committedControl.snapshot.issue; + syncState = await syncProject( + committedIssue, + committedIssue.body || '', + 'claim', + `@${commenterLogin} 通过 issue 评论认领本任务。` + ); + } catch (error) { + syncErrorMessage = error.message; + core.warning(`Project claim sync failed: ${error.message}`); + } + const syncedControl = await waitForClaimControl(commenterLogin, claimTokenId, true); + rememberOwnAssignment(syncedControl); + const syncedStatus = readFieldFromBody(syncedControl.snapshot?.issue?.body || '', '状态'); + if (!syncedControl.ok || syncedStatus !== 'In Progress') { + await rejectAfterAssignment({ + ...syncedControl, + reason: syncedControl.ok ? '任务状态在 Project 同步期间发生变化。' : syncedControl.reason, + }, canPromoteFrom(syncedControl)); + return; + } if (syncState === 'blocked') { core.setFailed(`Assigned issue #${issue.number} to @${commenterLogin}, but Project sync is blocked. ${syncErrorMessage}`); } else { diff --git a/.github/workflows/task-issue-sync.yml b/.github/workflows/task-issue-sync.yml index 7a4a8c2..aa1d3e1 100644 --- a/.github/workflows/task-issue-sync.yml +++ b/.github/workflows/task-issue-sync.yml @@ -18,10 +18,11 @@ permissions: jobs: sync: if: ${{ github.event_name == 'workflow_dispatch' || !github.event.issue.pull_request }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Sync task issue metadata - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PROJECTS_TOKEN: ${{ secrets.PROJECTS_TOKEN }} PROJECT_OWNER: ${{ vars.PROJECT_OWNER }} @@ -61,6 +62,7 @@ jobs: infra: 'area:devops', testing: 'area:testing', }; + const trustedAuthorAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); const projectGraphql = async (query, variables = {}) => { if (!process.env.PROJECTS_TOKEN) { @@ -125,6 +127,7 @@ jobs: const isManagedIssue = (issue) => { const issueBody = issue.body || ''; return !issue.pull_request && + trustedAuthorAssociations.has(issue.author_association) && Boolean(parseTaskCode(issue.title || '')) && readFieldFromBody(issueBody, 'GitHub Project') === projectName; }; @@ -261,6 +264,13 @@ jobs: per_page: 100, }); const existingLabelNames = new Set(existingLabels.map((label) => label.name)); + const currentIssueLabels = new Set((source.issue.labels || []).map((label) => ( + typeof label === 'string' ? label : label.name + ))); + const managedLabels = new Set([ + ...Object.values(groupLabelByGroup), + ...Object.values(moduleLabelByModule), + ]); const validLabels = [...labelsToAdd].filter((label) => existingLabelNames.has(label)); if (validLabels.length > 0) { await github.rest.issues.addLabels({ @@ -270,6 +280,19 @@ jobs: labels: validLabels, }); } + for (const label of currentIssueLabels) { + if (!managedLabels.has(label) || labelsToAdd.has(label)) continue; + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } }; let syncSucceeded = false; let syncErrorMessage = ''; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4218987..99dbaf3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ final 5. 提交修改。 6. 推送到个人 fork。 7. 创建 PR 到主仓库 `develop`。 -8. 等待 CI、PR Guard、Commitlint、Codex review 和人工 review。 +8. 等待 CI、PR Guard、Commitlint、已启用的 Codex review 和人工 review。 9. 通过后合并。 常用命令: @@ -95,7 +95,7 @@ git remote -v git fetch upstream --prune git switch -c Frontend/feat/login-page upstream/develop git status -git add . +git add git commit -m "feat(frontend): add login page" git push -u origin Frontend/feat/login-page ``` @@ -107,7 +107,7 @@ gh pr create \ --base develop \ --head YOUR_NAME:Frontend/feat/login-page \ --title "feat(frontend): add login page" \ - --body-file .github/pull_request_template.md + --template .github/pull_request_template.md ``` 如果开发期间 `develop` 更新: diff --git a/README.md b/README.md index 5645912..186aada 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,16 @@ 1. Fork 或复制本模板到你的项目仓库。 2. 修改 [docs/collaboration/repository-setup.md](docs/collaboration/repository-setup.md) 中的仓库名、团队名、Project 名称和 label。 -3. 在 GitHub 创建 Project,并添加模板要求的字段。 -4. 在 GitHub 仓库设置里启用 branch protection。 -5. 在 `Settings -> Secrets and variables -> Actions` 配置必要的 secrets 和 variables。 -6. 按 [.github/ISSUE_TEMPLATE/task_issue.md](.github/ISSUE_TEMPLATE/task_issue.md) 创建第一批任务。 -7. 成员评论 `认领:@自己的GitHub用户名` 开始任务。 -8. 成员从个人 fork 分支发 PR 到 `develop`。 -9. 等待 PR Guard、Commitlint、CI、Codex review 和人工 review 通过。 -10. 合并后关闭或自动关闭 issue,Project 状态和工时同步。 +3. 创建 `develop` 分支并把它设为默认分支。 +4. 在 GitHub 创建 Project,并添加模板要求的字段。 +5. 在 GitHub 仓库设置里启用 branch protection。 +6. 替换 [.github/CODEOWNERS](.github/CODEOWNERS) 中的维护者账号,并启用 Private vulnerability reporting。 +7. 在 `Settings -> Secrets and variables -> Actions` 配置必要的 secrets 和 variables。 +8. 按 [.github/ISSUE_TEMPLATE/task_issue.md](.github/ISSUE_TEMPLATE/task_issue.md) 创建第一批任务。 +9. 成员评论 `认领:@自己的GitHub用户名` 开始任务。 +10. 成员从个人 fork 分支发 PR 到 `develop`。 +11. 等待 PR Guard、Commitlint、CI、可选 Codex review 和人工 review 通过。 +12. 合并后关闭或自动关闭 issue,Project 状态和已记录工时同步。 ## 搭配 Trellis 使用 @@ -99,7 +101,7 @@ flowchart LR H --> J I --> J J --> K["合并 develop"] - K --> L["Issue 关闭 / Project Done / 工时回填"] + K --> L["Issue 关闭 / Project Done / 工时同步"] ``` ## 需要你按项目改掉的内容 @@ -108,7 +110,10 @@ flowchart LR - Group 选项,默认是 `Product`、`Backend`、`Frontend`、`DevOps`、`QA`、`Special`。 - 模块 label,默认是 `area:frontend`、`area:backend`、`area:devops`、`area:testing`、`area:docs`、`ci`。 - PR 目标分支,默认是 `develop`。 -- Codex review 的 OpenAI endpoint 和 token。 +- 仓库默认分支,推荐设为 `develop`。 +- 是否启用 Codex review,以及对应的 OpenAI token 和可选 endpoint。 +- CODEOWNERS 中的维护者账号,以及团队的私密安全联系方式。 +- Dependabot 的目标分支、更新频率和受信自动化账号。 - 项目自己的 CI,例如前端、后端、部署、测试命令。 ## 默认安全姿态 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4f44a38 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +请不要在公开 Issue 中披露安全漏洞。 + +请进入当前仓库的 `Security -> Advisories`,选择 `Report a vulnerability`,通过 GitHub 私有漏洞报告提交。 + +如果该入口不可见,请通过团队已公布的私密联系方式联系维护者,不要改用公开 Issue。仓库维护者应先在 `Settings -> Code security` 中启用 Private vulnerability reporting。 + +报告中请包含受影响版本或 commit、复现步骤、影响范围和建议修复方式。维护者会在确认后协调修复与披露。 diff --git a/docs/README.md b/docs/README.md index 0c848d8..f35fb3a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ 4. 文档维护者读 [collaboration/documentation-workflow.md](collaboration/documentation-workflow.md)。 5. 测试负责人读 [testing/strategy.md](testing/strategy.md)。 6. 使用 AI agent 的人读 [collaboration/agent-workflow.md](collaboration/agent-workflow.md)。 +7. 新仓库维护者按 [templates/project-kickoff-checklist.md](templates/project-kickoff-checklist.md) 完成启动检查。 ## 目录说明 diff --git a/docs/collaboration/overview.md b/docs/collaboration/overview.md index b2eadfd..166cec8 100644 --- a/docs/collaboration/overview.md +++ b/docs/collaboration/overview.md @@ -22,7 +22,7 @@ - `Task Issue Sync` 自动识别标准任务 issue。 - 自动同步 Project 状态、分组、优先级、模块、风险、依赖和工时。 - 自动补 label。 -- 同步失败时 `Project sync` 写为 `blocked`,维护者查看 workflow 日志。 +- 同步状态以 `Task Issue Sync` workflow run 为准;失败时维护者查看 run 日志。 ## 4. 任务认领 @@ -58,6 +58,6 @@ - 合并到 `develop`。 - issue 被 PR 自动关闭或手动关闭。 - Project 状态同步为 Done。 -- 实际工时自动回填,也可以评论 `实际工时:1.5` 手动修正。 +- 实际工时按 issue 正文已有值同步;维护者、协作者或当前 Assignee 可评论 `实际工时:1.5` 修正。 - 测试任务归档证据。 - 文档状态同步,不能把未合入能力写成已实现。 diff --git a/docs/collaboration/repository-setup.md b/docs/collaboration/repository-setup.md index 425f331..88af260 100644 --- a/docs/collaboration/repository-setup.md +++ b/docs/collaboration/repository-setup.md @@ -2,6 +2,12 @@ 本文面向仓库维护者,说明如何把这个模板接入一个新的 GitHub 项目。 +## 默认分支 + +创建或复制仓库后先创建 `develop`,并把它设为 GitHub 默认分支。日常 PR 进入 `develop`,`main` 只用于稳定版本和发布。 + +这项设置也决定 `Closes #123` 等 closing keyword 何时自动关闭 issue;只有合入默认分支的 PR 才会自动关闭关联 issue。 + ## 推荐 label ```text @@ -55,7 +61,7 @@ Project 建议字段: | `Group` | Single select | `Product`, `Backend`, `Frontend`, `DevOps`, `QA`, `Special` | | `Priority` | Single select | `P0`, `P1`, `P2` | | `Batch` | Single select | `Batch 0`, `Batch 1`, `Batch 2`, `Batch 3` | -| `Module` | Single select | 按项目模块设置,例如 `frontend`, `api`, `infra`, `docs` | +| `Module` | Single select | 默认需包含 `frontend`, `backend`, `api`, `docs`, `ci`, `infra`, `testing`;修改模板时同步调整。 | | `Risk` | Single select | `Normal`, `Needs Decision`, `Blocked` | | `Dependency` | Text | 上游 issue 引用或说明。 | | `ExpectedHours` | Number | 预期工时。 | @@ -68,7 +74,7 @@ Project 建议字段: | Secret | 用途 | | --- | --- | -| `PROJECTS_TOKEN` | 可选。访问 user-level 或 organization Project v2 的 token。 | +| `PROJECTS_TOKEN` | 启用 Project v2 同步时必需。访问 user-level 或 organization Project v2 的 token。 | | `OPENAI_API_KEY` | 可选。Codex PR Review 调用上游模型服务的 token。 | `PROJECTS_TOKEN` 应使用 fine-grained token 或 classic token,并授予目标 Project 读写权限。Issue 评论、label 和 assignee 仍由 GitHub 自动提供的 `GITHUB_TOKEN` 处理。 @@ -83,20 +89,21 @@ Project 建议字段: | `PROJECT_OWNER_TYPE` | `user` 或 `organization`。 | | `PROJECT_NUMBER` | Project 编号。 | | `PROJECT_NAME` | Project 标题,默认 `Team Project`。 | +| `CODEX_REVIEW_ENABLED` | `true` 时启用 Codex PR Review;未设置时不运行。 | | `OPENAI_RESPONSES_API_ENDPOINT` | Codex review 的 Responses API endpoint。 | ## Codex PR Review Codex review 由 [.github/workflows/codex-pr-review.yml](../../.github/workflows/codex-pr-review.yml) 配置。 -必要配置: +启用 Codex review 时的必要配置: ```text Secret: OPENAI_API_KEY -Variable: OPENAI_RESPONSES_API_ENDPOINT +Variable: CODEX_REVIEW_ENABLED=true ``` -官方 OpenAI endpoint 通常是: +`OPENAI_RESPONSES_API_ENDPOINT` 是可选覆盖项;留空时 action 使用默认 endpoint。官方 OpenAI endpoint 通常是: ```text https://api.openai.com/v1/responses @@ -104,6 +111,18 @@ https://api.openai.com/v1/responses 如果使用自建网关,它必须兼容 OpenAI Responses API,并支持 `openai/codex-action@v1` 实际使用的请求和响应格式。只支持旧 `/v1/chat/completions` 通常不够。 +Codex action 默认只允许对仓库有 write 权限的用户触发,不要设置 `allow-users: "*"`。Review job 使用只读 permission profile,发布评论在独立 job 中完成。 + +## CODEOWNERS 与安全报告 + +把 [.github/CODEOWNERS](../../.github/CODEOWNERS) 中的 `@JerryBlackoo` 替换为新仓库的维护者或团队。启用 Require review from Code Owners 前,先确认所有路径都有可用 owner。 + +在 `Settings -> Code security` 启用 Private vulnerability reporting,并按 [SECURITY.md](../../SECURITY.md) 检查团队的私密联系方式。安全入口不要硬编码到模板来源仓库。 + +## Dependabot + +[.github/dependabot.yml](../../.github/dependabot.yml) 默认每周检查 GitHub Actions,并向 `develop` 发 PR。`dependabot[bot]` 是 PR Guard 和 Commitlint 唯一默认受信自动化账号;增加其他机器人时必须同时审查其权限、PR 来源和守门豁免范围。 + ## develop 分支保护 建议在 GitHub 仓库页面设置: @@ -119,6 +138,7 @@ https://api.openai.com/v1/responses - `PR Guard` - `Commitlint` +- `Docs Check` - 项目自己的 CI,例如 frontend/backend/test/deploy checks `Auto Label` 不建议作为 required check,它是辅助自动化。 @@ -135,6 +155,7 @@ https://api.openai.com/v1/responses ## Workflow 权限原则 - 只读检查使用 `contents: read`。 +- PR Guard 和 Commitlint 使用只读 `pull_request` 事件,使 required check 绑定到待合并提交。 - 打 label 或评论 PR 需要 `issues: write` 或 `pull-requests: write`。 - 不依赖 GitHub 默认 token 权限。 - 不给读文件的 workflow 添加写权限。 diff --git a/docs/collaboration/task-issue-project-workflow.md b/docs/collaboration/task-issue-project-workflow.md index 53419c7..53a2bf6 100644 --- a/docs/collaboration/task-issue-project-workflow.md +++ b/docs/collaboration/task-issue-project-workflow.md @@ -48,7 +48,6 @@ - 阻塞任务。 - 并行任务。 - GitHub Project。 -- Project sync。 - 权威依据。 - 任务范围。 - 交付物。 @@ -66,7 +65,6 @@ - 预期工时(小时数):`1` - 实际工时(小时数):`0` - GitHub Project:`Team Project` -- Project sync:`pending` ``` 需要进一步确认: @@ -89,7 +87,7 @@ - 允许整数和小数,例如 `0`、`0.5`、`1.25`。 - 非 `Draft` 任务的预期工时必须大于 `0`。 - 实际工时初始可为 `0`。 -- 关闭任务时,自动化会尽量回填实际工时。 +- 关闭任务前应通过 issue 正文或评论命令确认实际工时;自动化不会用预期工时伪造实际工时。 - 维护者、协作者或当前 assignee 可评论 `实际工时:2` 手动更新。 ## 依赖规则 @@ -125,6 +123,7 @@ 识别条件: +- Issue 作者是仓库 `OWNER`、`MEMBER` 或 `COLLABORATOR`;外部用户创建的相似 Issue 不会写入内部 Project。 - 标题匹配 `[P-001] ...` 等标准编号。 - 正文包含 `GitHub Project:Team Project`,或你配置的 Project 名称。 - 正文包含可解析的任务字段。 @@ -139,15 +138,15 @@ | 同步 Priority / Batch / Module / Risk | Issue 正文字段。 | | 同步 ExpectedHours / ActualHours | Issue 工时字段。 | | 同步 Dependency | Issue `依赖任务`。 | -| 添加 label | 主责小组和模块。 | -| 回写 Project sync | 同步结果。 | +| 收敛 label | 添加当前主责小组和模块 label,并移除旧的受管小组/模块 label。 | -如果 `Project sync` 变为 `blocked`,维护者检查: +如果 `Task Issue Sync` workflow 失败,维护者检查: - Project 字段是否存在。 - `PROJECTS_TOKEN` 是否能访问 Project。 - Project name、owner、number 是否正确。 - Issue 正文字段是否保留模板格式。 +- Workflow run 中报告的具体字段、权限或 API 错误。 ## 认领 diff --git a/docs/templates/project-kickoff-checklist.md b/docs/templates/project-kickoff-checklist.md index e3fd07f..7c4de49 100644 --- a/docs/templates/project-kickoff-checklist.md +++ b/docs/templates/project-kickoff-checklist.md @@ -4,9 +4,12 @@ - [ ] 已创建主仓库。 - [ ] 已创建 `develop` 分支。 +- [ ] 已把 `develop` 设为默认分支。 - [ ] 已保护 `develop`。 - [ ] 已保护 `main`。 - [ ] 已创建基础 label。 +- [ ] 已把 `.github/CODEOWNERS` 替换为当前维护者或团队。 +- [ ] 已启用 Private vulnerability reporting,并核对 `SECURITY.md` 的私密联系方式。 - [ ] 已启用 Actions。 ## Project @@ -31,7 +34,9 @@ - [ ] 已配置 `PROJECT_NUMBER`。 - [ ] 已配置 `PROJECT_NAME`。 - [ ] 如果使用 Codex review,已配置 `OPENAI_API_KEY`。 -- [ ] 如果使用 Codex review,已配置 `OPENAI_RESPONSES_API_ENDPOINT`。 +- [ ] 如果使用 Codex review,已设置 `CODEX_REVIEW_ENABLED=true`。 +- [ ] 如果使用自建 Responses API gateway,已配置 `OPENAI_RESPONSES_API_ENDPOINT`。 +- [ ] 已确认 Dependabot 目标分支和 `dependabot[bot]` 守门豁免符合团队政策。 ## 团队规则 @@ -47,4 +52,4 @@ - [ ] 已创建上游契约 / 架构任务。 - [ ] 已创建前后端或模块任务。 - [ ] 已创建测试任务。 -- [ ] 已检查 `Project sync` 为 `synced`。 +- [ ] 已检查第一条任务对应的 `Task Issue Sync` workflow run 成功。