From 8c4014c931e513be5715a86e5ecab99f4e8fda85 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 21 Jul 2026 11:59:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ci):=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E3=80=8C=E6=88=91=E6=83=B3=E5=81=9A=E3=80=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=A4=E9=A2=86=20Issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Issue 下评论认领关键词时,优先写入 Assignees;无写权限则标记 claimed 并记录认领状态,避免双人撞车。 --- .github/workflows/issue-claim.yml | 293 ++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 .github/workflows/issue-claim.yml diff --git a/.github/workflows/issue-claim.yml b/.github/workflows/issue-claim.yml new file mode 100644 index 00000000..c51a1ecc --- /dev/null +++ b/.github/workflows/issue-claim.yml @@ -0,0 +1,293 @@ +# 评论认领:在 Issue 下评论「我想做」等关键词后,自动指派/标记认领人。 +# 说明: +# - 仓库协作者(write 及以上)可成功写入 Assignees +# - 外部贡献者若无写权限,GitHub 不允许设为 assignee;此时会打 claimed 标签并回复确认 +# - 支持取消认领:取消认领 / /unclaim +name: Issue Claim + +on: + issue_comment: + types: [created] + +permissions: + issues: write + contents: read + +jobs: + claim: + name: Auto claim or unclaim + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + steps: + - name: Process claim / unclaim comment + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const comment = context.payload.comment; + const actor = comment.user.login; + const actorType = comment.user.type; + + // 忽略机器人自己的评论,避免循环 + if (actorType === 'Bot') { + core.info('Skip bot comment'); + return; + } + + const raw = (comment.body || '').replace(/\r\n/g, '\n').trim(); + if (!raw) { + return; + } + + // 仅认领短指令:整段或首行匹配,避免长讨论误触发 + const firstLine = raw.split('\n').map((l) => l.trim()).find(Boolean) || ''; + const normalize = (s) => + s + .replace(/^[@/]/g, '') + .replace(/[!!。..、,,\s]+$/g, '') + .trim() + .toLowerCase(); + + const claimPhrases = new Set([ + '我想做', + '我来做', + '我来', + '认领', + '我认领', + 'claim', + 'i claim', + 'i want this', + 'i will take this', + 'assign me', + ]); + const unclaimPhrases = new Set([ + '取消认领', + '不认领了', + '放弃认领', + 'unclaim', + 'release', + ]); + + const nFull = normalize(raw); + const nFirst = normalize(firstLine); + // 允许 /claim /unclaim 形式 + const slash = firstLine.match(/^\/(claim|unclaim)\b/i); + const isClaim = + claimPhrases.has(nFull) || + claimPhrases.has(nFirst) || + (slash && slash[1].toLowerCase() === 'claim'); + const isUnclaim = + unclaimPhrases.has(nFull) || + unclaimPhrases.has(nFirst) || + (slash && slash[1].toLowerCase() === 'unclaim'); + + if (!isClaim && !isUnclaim) { + core.info('Comment is not a claim/unclaim command'); + return; + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = issue.number; + const CLAIMED_LABEL = 'claimed'; + + async function ensureClaimedLabel() { + try { + await github.rest.issues.getLabel({ owner, repo, name: CLAIMED_LABEL }); + } catch (e) { + if (e.status !== 404) throw e; + await github.rest.issues.createLabel({ + owner, + repo, + name: CLAIMED_LABEL, + color: '0E8A16', + description: '已被社区认领,进行中', + }); + } + } + + async function ensureLabels(names) { + const current = new Set((issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name))); + const toAdd = names.filter((n) => !current.has(n)); + if (toAdd.length) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd }); + } + } + + async function removeLabels(names) { + for (const name of names) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + async function tryAssign(login) { + try { + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number, + assignees: [login], + }); + return { ok: true }; + } catch (e) { + const msg = e.message || String(e); + core.warning(`assign failed for ${login}: ${msg}`); + return { ok: false, message: msg }; + } + } + + async function tryUnassign(login) { + try { + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number, + assignees: [login], + }); + return { ok: true }; + } catch (e) { + core.warning(`unassign failed for ${login}: ${e.message || e}`); + return { ok: false }; + } + } + + // 从 issue 正文底部的认领锚点解析当前认领人 + const CLAIM_MARKER_RE = + //; + const body = issue.body || ''; + const markerMatch = body.match(CLAIM_MARKER_RE); + const markerClaimer = markerMatch ? markerMatch[1] : null; + const assignees = (issue.assignees || []).map((a) => a.login); + const currentClaimer = assignees[0] || markerClaimer || null; + const hasClaimedLabel = (issue.labels || []).some( + (l) => (typeof l === 'string' ? l : l.name) === CLAIMED_LABEL + ); + + if (isUnclaim) { + if (!currentClaimer && !hasClaimedLabel) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `ℹ️ @${actor} 当前无人认领,无需取消。`, + }); + return; + } + if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) { + // 仅认领人本人可取消;维护者可强制取消 + const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes( + context.payload.comment.author_association + ); + if (!isMaintainer) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `⚠️ @${actor} 本 Issue 当前由 @${currentClaimer} 认领。请对方评论 \`取消认领\`,或联系维护者。`, + }); + return; + } + } + + const who = currentClaimer || actor; + await tryUnassign(who); + await removeLabels([CLAIMED_LABEL]); + + let newBody = body; + if (CLAIM_MARKER_RE.test(newBody)) { + newBody = newBody + .replace(/\n?\s*/g, '\n') + .replace(/\n?### 🔐 认领状态[\s\S]*?(?=\n## |\n# |$)/, '\n') + .trimEnd(); + await github.rest.issues.update({ owner, repo, issue_number, body: newBody }); + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `🔓 @${actor} 已取消认领${who !== actor ? `(原认领人 @${who})` : ''}。其他人可评论 \`我想做\` 重新认领。`, + }); + return; + } + + // —— claim —— + if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `⚠️ @${actor} 本 Issue 已由 @${currentClaimer} 认领。\n\n- 若对方长期无进展,可评论提醒,或由维护者 \`取消认领\` 后重开\n- 你也可以先在本线程说明想做的方向,维护者可协助拆分子任务`, + }); + return; + } + + if (currentClaimer && currentClaimer.toLowerCase() === actor.toLowerCase()) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: `✅ @${actor} 你已经是本 Issue 的认领人,无需重复认领。开干吧 💪`, + }); + return; + } + + await ensureClaimedLabel(); + const assignResult = await tryAssign(actor); + await ensureLabels([CLAIMED_LABEL]); + + // 在正文写入可机读认领锚点(assignee 失败时仍可防双认领) + const stamp = new Date().toISOString().slice(0, 10); + const claimBlock = + `\n\n### 🔐 认领状态\n\n` + + `- 认领人:@${actor}\n` + + `- 认领日期:${stamp}\n` + + `- 取消方式:评论 \`取消认领\` 或 \`/unclaim\`\n` + + `\n`; + + let newBody = body; + if (CLAIM_MARKER_RE.test(newBody)) { + newBody = newBody.replace( + CLAIM_MARKER_RE, + `` + ); + if (!/### 🔐 认领状态/.test(newBody)) { + newBody = newBody.trimEnd() + claimBlock; + } + } else { + newBody = newBody.trimEnd() + claimBlock; + } + await github.rest.issues.update({ owner, repo, issue_number, body: newBody }); + + if (assignResult.ok) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: + `✅ @${actor} 已认领本 Issue,并已加入 **Assignees**。\n\n` + + `建议下一步:\n` + + `1. 从 \`dev\` 拉分支:\`fix/*\` 或 \`feature/*\`\n` + + `2. 完成后向 **\`dev\`** 提 PR\n` + + `3. 若暂无法继续,请评论 \`取消认领\`\n\n` + + `贡献指南:https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: + `✅ @${actor} 已认领本 Issue(已标记 \`claimed\`)。\n\n` + + `> 说明:GitHub 仅允许将**仓库协作者**设为正式 Assignee。你目前可能还没有写权限,因此无法写入 Assignees 字段;**社区认领仍然有效**,其他人再评论「我想做」会被拦截。\n\n` + + `建议下一步:\n` + + `1. Fork 仓库,从 \`dev\` 拉分支开发\n` + + `2. 向 **\`dev\`** 提 PR,并在 PR 描述里写 \`Fixes #${issue_number}\`\n` + + `3. 若暂无法继续,请评论 \`取消认领\`\n\n` + + `贡献指南:https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`, + }); + }