diff --git a/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md b/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md new file mode 100644 index 000000000..864c46c4d --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md @@ -0,0 +1,109 @@ +# Agent 笔记:非维护者必须通过 Issue 表单提交 + +状态:已实现 + +## 先说结论 + +`blank_issues_enabled: false` 只隐藏网页上的空白 Issue 入口,挡不住 API、`gh issue create` 和第三方客户端。现在每个 Issue 表单都带一个 `issue-form:*` 标签,`.github/workflows/lithe-issue-form-gate.yml` 在 Issue 创建或重新打开时检查:对仓库有 write、maintain 或 admin 权限的作者直接放行;其他作者没有这类标签就收到中英双语说明,Issue 被关闭。任何拿不准的情况都保留 Issue,宁可漏关,不误关。 + +## 问题 + +#988 是一个正文为空、没有经过表单的 Issue。只检查正文是否为空不够:通过 API 随便写一句话,同样绕过了表单,表单里的平台、模块、优先级等字段都缺失,`lithe-issue-priority.yml` 也无法自动分类。 + +需要一个普通用户无法伪造、只有表单才会产生的信号。 + +## 决策 + +### 用表单标签作为来源证明 + +Issue 表单(Issue Form,`.github/ISSUE_TEMPLATE/*.yml` 定义的结构化模板)顶层的 `labels:` 会在提交时自动加到 Issue 上,与提交者权限无关。REST API 创建 Issue 时,没有 push 权限的用户传入的 labels 会被 GitHub 静默丢弃;网页 URL 里的 `labels=` 参数同样要求打标签权限。所以普通用户只有通过表单才能得到 `issue-form:*` 标签。 + +允许的标签和对应模板集中在 `.github/lithe-issue-form/logic.mjs` 的 `ISSUE_FORMS`。新增表单时,在表单的 `labels:` 加一个 `issue-form:<类型>`,同时把它加入 `ISSUE_FORMS`,并在仓库里创建同名标签。不要只改其中一处;测试会检查每个登记的模板都声明了自己的标签。 + +### 判定顺序 + +`decideIssueFormGate` 返回 `allow`、`close` 或 `skip`,工作流只在 `close` 时评论并关闭: + +1. 不是开放状态的 Issue、Pull Request、缺少作者信息:`skip`。 +2. 作者是 Bot:`allow`。只有维护者安装的 App 或工作流才能以 Bot 身份建 Issue。 +3. 触发者不是作者(例如维护者转移或重新打开 Issue):`skip`。 +4. 读取作者权限失败:`skip`。 +5. 作者权限是 write、maintain、admin(`role_name` 或旧 `permission` 字段任一命中,后者覆盖自定义角色):`allow`。 +6. Issue 带任一允许的 `issue-form:*` 标签:`allow`。 +7. 仓库里缺少任一允许的标签,或任一标签已归档:`skip`。表单无法应用不存在或已归档的标签,此时无法区分“来自表单”和“绕过表单”。 +8. 其余情况:`close`,`state_reason` 为 `not_planned`。 + +权限以 `GET /repos/{owner}/{repo}/collaborators/{username}/permission` 为准,不以 `author_association` 为准:`COLLABORATOR` 可能只有 read 或 triage,`MEMBER` 也不代表对本仓库有写权限。工作流级 `if` 只跳过 `OWNER`、Bot 和 Pull Request,省掉一次 runner 启动,其余作者都由脚本查询真实权限。 + +### 标签时序 + +表单标签不一定和 Issue 同一时刻写入:#987 的 `bug` 标签比创建时间晚约 1 秒,事件记录的操作者是作者本人。所以工作流合并事件里的标签和重新读取的标签,并在真正关闭前再读一次 Issue、重新判定;只要此时出现表单标签,或 Issue 已被关闭,就不再处理。 + +### 评论与幂等 + +关闭前的评论带隐藏标记 ``,重跑工作流时如果已经有机器人发的同标记评论就不再重复评论。评论只引用作者 login 和固定的模板链接,不回显 Issue 标题或正文,避免把用户输入带进机器人内容。脚本通过 `actions/github-script` 的 API 客户端读取事件数据,不把标题或正文插入 shell 或 `${{ }}` 表达式,因此不存在脚本注入面。 + +### 和现有 Issue 自动化的关系 + +- `lithe-issue-priority.yml` 在 `opened` 和 `edited` 时只增删自己管理的 `bug`、`enhancement`、`P*`、`platform:*`、`area:*`,`issue-form:*` 会被保留。它和本工作流并行运行;禁止用旧快照执行 `setLabels` 整体替换,否则会抹掉读取后 GitHub 才补上的来源标签。使用 `addLabels` 和 `removeLabel`,让非托管标签始终保留。 +- `lithe-issue-claim.yml` 只处理评论事件,被关闭的 Issue 仍可由维护者重新打开后认领。 +- 维护者手动创建的 Issue 不需要 `issue-form:*` 标签。 + +### 正确做法 + +- 新增表单:模板 `labels:` 加 `issue-form:<类型>`,`ISSUE_FORMS` 登记,先在仓库创建标签再合并。 +- 误关时:由维护者重新打开 Issue,触发者与作者不同会跳过门禁。作者自己重新打开会再次检查,不能通过“立即关闭 → 等待门禁跳过 → 重新打开”绕过。 + +### 不要这样做 + +- 不要改成只检查正文是否为空,API 写一句话就能绕过。 +- 不要用 `author_association` 判断维护者,它不等于仓库写权限。 +- 不要在 `run:` 步骤里用 `${{ github.event.issue.title }}` 或正文拼 shell 命令。 +- 不要在缺少或归档标签时“先关再说”,那会关闭所有正常的表单 Issue。 + +## 考虑过的备选方案 + +### 只拦截空正文 + +实现最简单,也能处理 #988。但 API 或 CLI 写任意一句话即可绕过,而这类 Issue 同样缺少表单字段,所以没有采用。 + +### 按正文里的表单标题结构识别 + +表单正文有固定的 `### 字段名` 结构,不需要额外标签。但普通用户可以照抄这种 Markdown 结构伪造,字段文案调整时还要同步修改判定,所以没有采用。 + +### 仓库级只允许协作者创建 Issue + +服务端彻底限制,但普通用户将完全无法反馈问题,违背开放反馈的目标,所以没有采用。 + +### 缺少标签时由工作流自动创建 + +能让门禁“自己修好”,但工作流会因此需要隐式改仓库配置,也会在标签被维护者有意删除后悄悄恢复,所以改为缺少时放行并在日志里警告。 + +## 后果 + +- 收益:非维护者无论从哪个入口提交,都会留下完整表单字段,自动分类可以生效;被关闭的作者能看到双语说明和正确入口。 +- 代价:每个非 OWNER 作者的新 Issue 都会启动一次短时 runner(作业超时 5 分钟)。普通用户从其他仓库转移进来的 Issue、维护者手动删掉 `issue-form:*` 标签的 Issue 不受门禁约束。 +- 代价:如果 GitHub 改变“非 push 用户的 labels 静默丢弃”的行为,或允许 read 角色通过 URL 参数打标签,这个信号就能被伪造,需要重新评估。 +- 重新评估触发条件:GitHub 提供原生的“必须使用表单”设置;新增表单或改名模板文件;评论或关闭策略改变。 + +## 验证 + +```bash +node --test --test-timeout=10000 scripts/test-lithe-issue-*.mjs +actionlint .github/workflows/lithe-issue-form-gate.yml .github/workflows/lithe-issue-priority.yml .github/workflows/verify-issue-automation.yml +./scripts/verify-agent-notes.sh +``` + +测试覆盖 Bug、Feature 表单放行,空白 Issue 和带正文但无表单标签的 Issue 被关闭,write、maintain、admin 与自定义角色放行,以及权限未知、标签缺失、转移等不确定情况不关闭。工作流编排测试执行实际的 github-script 脚本,以模拟 API 控制“分类读取 → GitHub 补标签 → 分类写入 → 门禁检查”的顺序,同时覆盖作者重新打开、维护者重新打开、标签归档及权限读取失败。所有测试均不访问网络、不依赖真实延时。`verify-issue-automation.yml` 在相关 PR 和 main/preview 推送时执行这些测试。 + +## 适用范围 + +- `.github/workflows/lithe-issue-form-gate.yml` +- `.github/lithe-issue-form/logic.mjs` +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `.github/ISSUE_TEMPLATE/feature_request.yml` +- `scripts/test-lithe-issue-form-gate.mjs` + +- `scripts/test-lithe-issue-workflows.mjs` +- `.github/workflows/verify-issue-automation.yml` +- `.github/workflows/lithe-issue-priority.yml` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b432d8960..a4c394275 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,6 +3,9 @@ description: Report a reproducible problem in Lithe / 报告可复现的问题 title: "[Bug] " labels: - bug + # Marks issues created from this form; .github/workflows/lithe-issue-form-gate.yml + # closes non-maintainer issues without an issue-form:* label. + - issue-form:bug body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 36bebe5ad..eae440a25 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,9 @@ description: Suggest an improvement or a new capability / 建议改进现有能 title: "[Feature] " labels: - enhancement + # Marks issues created from this form; .github/workflows/lithe-issue-form-gate.yml + # closes non-maintainer issues without an issue-form:* label. + - issue-form:feature body: - type: markdown attributes: diff --git a/.github/lithe-issue-form/logic.mjs b/.github/lithe-issue-form/logic.mjs new file mode 100644 index 000000000..394a32164 --- /dev/null +++ b/.github/lithe-issue-form/logic.mjs @@ -0,0 +1,104 @@ +// Decision logic for .github/workflows/lithe-issue-form-gate.yml. +// Note: 决策记录见 .agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md + +// Each supported Issue Form declares exactly one of these labels in its +// `labels:` list. GitHub applies form labels regardless of the author's +// permission, while labels passed through the REST API or `gh issue create` +// are silently dropped for users without push access, so the label is a +// marker that non-maintainers cannot forge. +export const ISSUE_FORMS = Object.freeze([ + Object.freeze({ label: 'issue-form:bug', template: 'bug_report.yml', title: 'Bug 报告 / Bug report' }), + Object.freeze({ label: 'issue-form:feature', template: 'feature_request.yml', title: '功能建议 / Feature request' }), +]); + +export const ALLOWED_LABELS = Object.freeze(ISSUE_FORMS.map((form) => form.label)); + +// Hidden marker that identifies the gate's own comment, so a re-run does not +// post a duplicate explanation. +export const GATE_MARKER = ''; + +const MAINTAINER_PERMISSIONS = new Set(['admin', 'maintain', 'write']); + +export function labelNames(labels = []) { + return labels + .map((label) => (typeof label === 'string' ? label : label?.name)) + .filter(Boolean); +} + +// `role_name` reports admin/maintain/write/triage/read or a custom role name; +// the legacy `permission` field still carries the base level for custom roles. +export function isMaintainerPermission(permission) { + if (!permission) return false; + return MAINTAINER_PERMISSIONS.has(permission.role_name) || MAINTAINER_PERMISSIONS.has(permission.permission); +} + +export function missingAllowedLabels(existingRepoLabels, allowedLabels = ALLOWED_LABELS) { + const existing = new Set(existingRepoLabels); + return allowedLabels.filter((label) => !existing.has(label)); +} + +// Returns { action, reason } where action is: +// - 'allow': the issue stays open; +// - 'close': comment and close the issue; +// - 'skip': the gate cannot decide safely and leaves the issue untouched. +// Every uncertain input fails open so that a form-created issue is never +// closed because of a configuration gap or a transient API failure. +export function decideIssueFormGate({ + issue, + sender, + authorPermission, + missingLabels = [], + allowedLabels = ALLOWED_LABELS, +}) { + if (!issue) return { action: 'skip', reason: 'missing-issue' }; + if (issue.pull_request) return { action: 'skip', reason: 'pull-request' }; + if (issue.state && issue.state !== 'open') return { action: 'skip', reason: 'not-open' }; + const author = issue.user?.login; + if (!author) return { action: 'skip', reason: 'missing-author' }; + // Bot accounts can only create issues here through an app or workflow that + // a maintainer installed on this repository. + if (issue.user.type === 'Bot') return { action: 'allow', reason: 'bot-author' }; + // Preserve another actor's transfer or reopen decision. Author reopens + // still pass through the gate, so closing and reopening cannot bypass it. + if (sender?.login && sender.login !== author) return { action: 'skip', reason: 'opened-by-another-actor' }; + if (authorPermission === null || authorPermission === undefined) return { action: 'skip', reason: 'permission-unknown' }; + if (isMaintainerPermission(authorPermission)) return { action: 'allow', reason: 'maintainer' }; + const labels = labelNames(issue.labels); + if (labels.some((label) => allowedLabels.includes(label))) return { action: 'allow', reason: 'issue-form' }; + // Issue Forms cannot apply missing or archived labels. Until every + // allowed label exists, an unlabeled issue may still come from a form. + if (missingLabels.length > 0) return { action: 'skip', reason: 'form-labels-missing' }; + return { action: 'close', reason: 'not-from-issue-form' }; +} + +export function hasGateComment(comments = []) { + return comments.some((comment) => + comment.user?.type === 'Bot' && + comment.user?.login === 'github-actions[bot]' && + (comment.body ?? '').includes(GATE_MARKER) + ); +} + +export function issueFormLinks(serverUrl, owner, repo) { + const base = `${serverUrl}/${owner}/${repo}/issues/new`; + return { + chooser: `${base}/choose`, + forms: ISSUE_FORMS.map((form) => ({ title: form.title, url: `${base}?template=${encodeURIComponent(form.template)}` })), + }; +} + +// The author login comes from GitHub's account model (alphanumerics and +// hyphens); no title or body text is echoed back into the comment. +export function gateComment({ login, serverUrl, owner, repo }) { + const links = issueFormLinks(serverUrl, owner, repo); + const formLines = links.forms.map((form) => `- ${form.title}:${form.url}`).join('\n'); + return [ + GATE_MARKER, + `@${login} 感谢反馈。本仓库要求通过 Issue 表单提交 Issue。这个 Issue 没有经过表单创建(例如空白 Issue,或通过 API、CLI、第三方客户端直接创建),因此已自动关闭。请通过下面的入口重新提交:`, + '', + formLines, + `- 全部模板 / All templates:${links.chooser}`, + '', + 'Thanks for the report. This repository only accepts issues created from its issue forms. This issue was not created from a form (for example a blank issue, or one created through the API, CLI, or a third-party client), so it was closed automatically. Please resubmit it using one of the links above.', + ].join('\n'); +} diff --git a/.github/workflows/lithe-issue-form-gate.yml b/.github/workflows/lithe-issue-form-gate.yml new file mode 100644 index 000000000..a00360899 --- /dev/null +++ b/.github/workflows/lithe-issue-form-gate.yml @@ -0,0 +1,94 @@ +name: Lithe issue form gate + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +concurrency: + group: lithe-issue-form-gate-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + gate: + # Skip pull requests, bots, and repository owners; all other authors + # are checked against their actual repository permission. + if: >- + github.event.issue.pull_request == null && + github.event.issue.user.type != 'Bot' && + github.event.issue.author_association != 'OWNER' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/github-script@v7 + env: + ISSUE_FORM_LOGIC: ${{ github.workspace }}/.github/lithe-issue-form/logic.mjs + with: + github-token: ${{ github.token }} + script: | + const logic = await import(process.env.ISSUE_FORM_LOGIC); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const payloadIssue = context.payload.issue; + const login = payloadIssue.user.login; + + // Re-read the issue so a maintainer action that already closed it + // is respected; keep the labels delivered with the opened event too. + const current = (await github.rest.issues.get({ owner, repo, issue_number })).data; + const labels = [...new Set([...logic.labelNames(payloadIssue.labels), ...logic.labelNames(current.labels)])]; + const issue = { ...current, user: current.user ?? payloadIssue.user, labels }; + + // Non-collaborators receive a 200 response with read/none, so any + // error means the permission is unknown and the issue stays open. + let authorPermission = null; + try { + authorPermission = (await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login })).data; + } catch (error) { + core.warning(`Unable to read permission for ${login}; leaving the issue open: ${error.message}`); + } + + // Issue Forms drop labels that do not exist, so a missing or + // unreadable allowed label disables closing instead of misfiring. + const existingLabels = []; + for (const name of logic.ALLOWED_LABELS) { + try { + const label = (await github.rest.issues.getLabel({ owner, repo, name })).data; + if (label.archived_at == null) existingLabels.push(name); + } catch (error) { + if (error.status !== 404) core.warning(`Unable to read label ${name}: ${error.message}`); + } + } + const missingLabels = logic.missingAllowedLabels(existingLabels); + if (missingLabels.length) core.warning(`Issue form labels are missing or archived (${missingLabels.join(', ')}); the gate will not close unlabeled issues until they exist.`); + + const decision = logic.decideIssueFormGate({ issue, sender: context.payload.sender, authorPermission, missingLabels }); + core.info(`Issue #${issue_number} by ${login}: ${decision.action} (${decision.reason})`); + if (decision.action !== 'close') return; + + // Form labels can be written a moment after the issue is created, + // and a maintainer may act meanwhile; re-check right before closing. + const latest = (await github.rest.issues.get({ owner, repo, issue_number })).data; + const recheck = logic.decideIssueFormGate({ + issue: { ...latest, user: latest.user ?? payloadIssue.user, labels: [...labels, ...logic.labelNames(latest.labels)] }, + sender: context.payload.sender, + authorPermission, + missingLabels, + }); + if (recheck.action !== 'close') { + core.info(`Issue #${issue_number} recheck: ${recheck.action} (${recheck.reason})`); + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + if (!logic.hasGateComment(comments)) { + const body = logic.gateComment({ login, serverUrl: context.serverUrl, owner, repo }); + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + await github.rest.issues.update({ owner, repo, issue_number, state: 'closed', state_reason: 'not_planned' }); diff --git a/.github/workflows/lithe-issue-priority.yml b/.github/workflows/lithe-issue-priority.yml index e3909d4ab..ec7d208ac 100644 --- a/.github/workflows/lithe-issue-priority.yml +++ b/.github/workflows/lithe-issue-priority.yml @@ -71,11 +71,11 @@ jobs: const currentLabels = issue.labels .map((label) => typeof label === 'string' ? label : label.name) .filter(Boolean); - const nextLabels = currentLabels.filter((label) => !managedLabels.includes(label)); - nextLabels.push(...selectedLabels); - const unchanged = currentLabels.length === nextLabels.length && - currentLabels.every((label, index) => label === nextLabels[index]); - if (unchanged) return; + // Only mutate labels owned by this workflow. Replacing the whole + // list can erase form labels applied after the snapshot was read. + const removedLabels = currentLabels.filter((label) => managedLabels.includes(label) && !selectedLabels.includes(label)); + const addedLabels = selectedLabels.filter((label) => !currentLabels.includes(label)); + if (!removedLabels.length && !addedLabels.length) return; for (const name of selectedLabels) { try { await github.rest.issues.getLabel({ owner, repo, name }); @@ -86,4 +86,13 @@ jobs: }); } } - await github.rest.issues.setLabels({ owner, repo, issue_number, labels: nextLabels }); + for (const name of removedLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (addedLabels.length) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: addedLabels }); + } diff --git a/.github/workflows/verify-issue-automation.yml b/.github/workflows/verify-issue-automation.yml new file mode 100644 index 000000000..799ddc36b --- /dev/null +++ b/.github/workflows/verify-issue-automation.yml @@ -0,0 +1,35 @@ +name: Verify issue automation + +on: + pull_request: + paths: + - '.github/ISSUE_TEMPLATE/**' + - '.github/lithe-issue-*/**' + - '.github/workflows/lithe-issue-*.yml' + - '.github/workflows/verify-issue-automation.yml' + - 'scripts/test-lithe-issue-*.mjs' + push: + branches: [main, preview] + paths: + - '.github/ISSUE_TEMPLATE/**' + - '.github/lithe-issue-*/**' + - '.github/workflows/lithe-issue-*.yml' + - '.github/workflows/verify-issue-automation.yml' + - 'scripts/test-lithe-issue-*.mjs' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Test issue decisions and workflow orchestration + run: node --test --test-timeout=10000 scripts/test-lithe-issue-*.mjs diff --git a/scripts/test-lithe-issue-form-gate.mjs b/scripts/test-lithe-issue-form-gate.mjs new file mode 100644 index 000000000..f4f73a6c3 --- /dev/null +++ b/scripts/test-lithe-issue-form-gate.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + ALLOWED_LABELS, GATE_MARKER, ISSUE_FORMS, decideIssueFormGate, gateComment, hasGateComment, isMaintainerPermission, labelNames, missingAllowedLabels, +} from '../.github/lithe-issue-form/logic.mjs'; + +const readPermission = { permission: 'read', role_name: 'read' }; +const issueBy = (login, overrides = {}) => ({ + number: 1, state: 'open', user: { login, type: 'User' }, labels: [], body: '', ...overrides, +}); +const decide = (issue, authorPermission, extra = {}) => + decideIssueFormGate({ issue, sender: { login: issue.user.login }, authorPermission, ...extra }); + +test('every issue form declares its gate label', () => { + for (const form of ISSUE_FORMS) { + const source = readFileSync(new URL(`../.github/ISSUE_TEMPLATE/${form.template}`, import.meta.url), 'utf8'); + assert.match(source, new RegExp(`^\\s*-\\s*${form.label}\\s*$`, 'm'), `${form.template} must list ${form.label}`); + } +}); + +test('non-maintainer issues from the bug and feature forms stay open', () => { + assert.deepEqual(decide(issueBy('alice', { labels: [{ name: 'bug' }, { name: 'issue-form:bug' }] }), readPermission), { action: 'allow', reason: 'issue-form' }); + assert.deepEqual(decide(issueBy('alice', { labels: [{ name: 'enhancement' }, { name: 'issue-form:feature' }] }), readPermission), { action: 'allow', reason: 'issue-form' }); +}); + +test('a blank non-maintainer issue is closed', () => { + assert.deepEqual(decide(issueBy('alice', { body: null }), readPermission), { action: 'close', reason: 'not-from-issue-form' }); +}); + +test('an API or CLI issue with a non-empty body but no form label is closed', () => { + const issue = issueBy('alice', { title: '[Bug] crash', body: 'please support this', labels: [{ name: 'bug' }] }); + assert.deepEqual(decide(issue, readPermission), { action: 'close', reason: 'not-from-issue-form' }); +}); + +test('regression #988: an outside user with an empty body is closed', () => { + const issue = issueBy('outside-user', { title: '能否做一个热更新的功能', body: null, labels: [{ name: 'review: low' }] }); + assert.equal(decide(issue, { permission: 'none', role_name: 'none' }).action, 'close'); +}); + +test('write, maintain, and admin authors may open blank issues', () => { + for (const level of ['write', 'maintain', 'admin']) { + assert.deepEqual(decide(issueBy('maintainer'), { permission: level, role_name: level }), { action: 'allow', reason: 'maintainer' }); + } + // Custom roles keep their base level in the legacy permission field. + assert.equal(decide(issueBy('custom'), { permission: 'write', role_name: 'release-manager' }).action, 'allow'); + assert.equal(isMaintainerPermission({ permission: 'read', role_name: 'triage' }), false); + assert.equal(isMaintainerPermission(null), false); +}); + +test('uncertain inputs never close an issue', () => { + assert.deepEqual(decide(issueBy('alice'), null), { action: 'skip', reason: 'permission-unknown' }); + assert.deepEqual(decide(issueBy('alice'), readPermission, { missingLabels: ['issue-form:bug'] }), { action: 'skip', reason: 'form-labels-missing' }); + assert.equal(decide(issueBy('alice', { state: 'closed' }), readPermission).action, 'skip'); + assert.equal(decide(issueBy('alice', { pull_request: { url: 'x' } }), readPermission).action, 'skip'); + assert.equal(decideIssueFormGate({ issue: issueBy('alice'), sender: { login: 'maintainer' }, authorPermission: readPermission }).reason, 'opened-by-another-actor'); + assert.equal(decideIssueFormGate({ issue: null }).action, 'skip'); +}); + +test('bot authors are allowed', () => { + assert.equal(decide(issueBy('dependabot[bot]', { user: { login: 'dependabot[bot]', type: 'Bot' } }), readPermission).action, 'allow'); +}); + +test('label helpers accept string and object labels', () => { + assert.deepEqual(labelNames(['bug', { name: 'issue-form:bug' }, {}, null]), ['bug', 'issue-form:bug']); + assert.deepEqual(missingAllowedLabels(['issue-form:bug']), ['issue-form:feature']); + assert.deepEqual(missingAllowedLabels(ALLOWED_LABELS), []); +}); + +test('the gate comment is bilingual, links every form, and never echoes issue text', () => { + const body = gateComment({ login: 'alice', serverUrl: 'https://github.com', owner: '1lck', repo: 'Lithe-IDEA' }); + assert.ok(body.startsWith(GATE_MARKER)); + assert.match(body, /@alice/); + assert.match(body, /Issue 表单/); + assert.match(body, /issue forms/); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new?template=bug_report.yml')); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new?template=feature_request.yml')); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new/choose')); +}); + +test('only the workflow bot comment counts as an existing gate comment', () => { + assert.equal(hasGateComment([{ user: { login: 'alice', type: 'User' }, body: GATE_MARKER }]), false); + assert.equal(hasGateComment([{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: `${GATE_MARKER}\nclosed` }]), true); + assert.equal(hasGateComment([]), false); +}); diff --git a/scripts/test-lithe-issue-workflows.mjs b/scripts/test-lithe-issue-workflows.mjs new file mode 100644 index 000000000..7a4d223ea --- /dev/null +++ b/scripts/test-lithe-issue-workflows.mjs @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const workflowSource = (name) => readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), 'utf8'); +const gateSource = workflowSource('lithe-issue-form-gate'); +const prioritySource = workflowSource('lithe-issue-priority'); +const logicUrl = new URL('../.github/lithe-issue-form/logic.mjs', import.meta.url).href; + +// Execute the actual checked-in github-script body with deterministic API doubles. +// Pass a local process double so tests do not mutate shared environment variables. +async function run(source, fixture) { + const marker = ' script: |\n'; + assert.equal(source.split(marker).length, 2); + const body = source.split(marker)[1].split('\n').map((line) => line.slice(12)).join('\n'); + await new AsyncFunction('github', 'context', 'core', 'process', body)( + fixture.github, fixture.context, { info() {}, warning() {} }, + { env: { ISSUE_FORM_LOGIC: logicUrl } }, + ); +} + +function fixture({ archived = false, permission = 'read', onRead = () => {}, sender = 'alice' } = {}) { + const issue = { number: 1, state: 'open', user: { login: 'alice', type: 'User' }, labels: [], title: '[Bug] example', body: '### Priority / 优先级\nP1' }; + const context = { repo: { owner: 'example', repo: 'test' }, issue: { number: 1 }, payload: { issue: structuredClone(issue), sender: { login: sender } }, serverUrl: 'https://github.com' }; + const comments = []; + const removals = []; + let reads = 0; + const github = { + rest: { + issues: { + get: async () => { + const snapshot = structuredClone(issue); + onRead(issue, ++reads); + return { data: snapshot }; + }, + getLabel: async () => ({ data: { archived_at: archived ? '2026-01-01T00:00:00Z' : null } }), + addLabels: async ({ labels }) => { + for (const name of labels) if (!issue.labels.some((label) => label.name === name)) issue.labels.push({ name }); + }, + removeLabel: async ({ name }) => { + removals.push(name); + issue.labels = issue.labels.filter((label) => label.name !== name); + }, + listComments: async () => ({ data: comments }), + createComment: async ({ body }) => comments.push({ user: { login: 'github-actions[bot]', type: 'Bot' }, body }), + update: async ({ state, state_reason }) => Object.assign(issue, { state, state_reason }), + }, + repos: { getCollaboratorPermissionLevel: async () => ({ data: { permission, role_name: permission } }) }, + }, + paginate: async (method, args) => (await method(args)).data, + }; + return { issue, context, github, comments, removals }; +} + +test('author close then reopen is checked again, without duplicating comments', async () => { + assert.match(gateSource, /types: \[opened, reopened\]/); + const f = fixture(); + f.issue.state = 'closed'; + await run(gateSource, f); + assert.equal(f.comments.length, 0); + f.context.payload.action = 'reopened'; + f.issue.state = 'open'; + await run(gateSource, f); + assert.equal(f.issue.state, 'closed'); + assert.equal(f.issue.state_reason, 'not_planned'); + assert.equal(f.comments.length, 1); + f.issue.state = 'open'; + await run(gateSource, f); + assert.equal(f.issue.state, 'closed'); + assert.equal(f.comments.length, 1); +}); + +test('another actor reopening an external issue is respected', async () => { + const f = fixture({ sender: 'maintainer' }); + f.context.payload.action = 'reopened'; + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('archived form labels disable closing even when getLabel succeeds', async () => { + const f = fixture({ archived: true }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('priority synchronization preserves form labels applied after its read', async () => { + // opened payload and priority snapshot have no form label. GitHub applies it + // before priority writes; gate starts only after priority has finished. + const f = fixture({ onRead(issue, count) { + if (count === 1) issue.labels.push({ name: 'issue-form:bug' }); + } }); + f.issue.labels.push({ name: 'P2' }, { name: 'claimed' }); + await run(prioritySource, f); + assert.deepEqual(f.removals, ['P2']); + assert.deepEqual(f.issue.labels.map((label) => label.name).sort(), ['P1', 'bug', 'claimed', 'issue-form:bug']); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('a form label appearing between gate reads prevents closing', async () => { + const f = fixture({ onRead(issue, count) { + if (count === 1) issue.labels.push({ name: 'issue-form:feature' }); + } }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('permission lookup failure leaves the issue open', async () => { + const f = fixture(); + f.github.rest.repos.getCollaboratorPermissionLevel = async () => { throw new Error('unavailable'); }; + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('maintainer authors can still submit without form labels', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const f = fixture({ permission }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); + } +});