diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 85aa2b0c..bd1bedd0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,11 @@ ## 关联 Issue - + ## 变更说明 @@ -10,6 +14,7 @@ ## 提交前检查 +- [ ] 目标分支为 `dev`,正文已关联符合上述要求的 issue - [ ] 本 PR 聚焦单一目标,未夹带无关改动 - [ ] 已通过 `pnpm lint` 与 `pnpm typecheck` - [ ] 已补充/更新相关测试,且 `pnpm test` 通过 diff --git a/.github/scripts/pr-policy-guard.cjs b/.github/scripts/pr-policy-guard.cjs new file mode 100644 index 00000000..970550f8 --- /dev/null +++ b/.github/scripts/pr-policy-guard.cjs @@ -0,0 +1,204 @@ +const COMMENT_MARKER = ''; +const TEMPLATE_LABELS = new Set(['bug', 'enhancement']); +const PRIVILEGED_AUTHORS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + +function prose(body) { + let fence = null; + return (body || '') + .replace(/|$)/g, '') + .split(/\r?\n/) + .map((line) => { + const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + + if (fence) { + if ( + marker && + marker[1][0] === fence[0] && + marker[1].length >= fence.length && + !marker[2].trim() + ) { + fence = null; + } + + return ''; + } + + if (marker) { + fence = marker[1]; + return ''; + } + + if (/^(?: {4}|\t|\s*>)/.test(line)) { + return ''; + } + + return line; + }) + .join('\n') + .replace(/(`+)[\s\S]*?\1/g, ''); +} + +function referencedIssues(body, context) { + const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase(); + const references = new Set(); + const pattern = + /\b(?:refs?|close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+(https?:\/\/[^\s<>]+|(?:[\w.-]+\/[\w.-]+)?#[1-9]\d*)(?![\w/#-])/gi; + + for (const match of prose(body).matchAll(pattern)) { + const reference = match[1].replace(/[),.;!?]+$/, ''); + let number; + + if (/^https?:\/\//i.test(reference)) { + let url; + + try { + url = new URL(reference); + } catch { + continue; + } + + const path = url.pathname.match(/^\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)\/?$/i); + + if (!path || url.origin !== context.serverUrl || path[1].toLowerCase() !== repository) { + continue; + } + + number = Number(path[2]); + } else { + const [name, id] = reference.split('#'); + + if (name && name.toLowerCase() !== repository) { + continue; + } + + number = Number(id); + } + + if (Number.isSafeInteger(number) && number > 0) { + references.add(number); + } + } + + return [...references]; +} + +async function violation(pr, github, context) { + if (pr.base.ref !== 'dev') { + return 'PR 的目标分支必须是 `dev`;`main` 仅由维护者的发布流程更新。'; + } + + const numbers = referencedIssues(pr.body, context); + + if (numbers.length === 0) { + return 'PR 正文缺少本仓库 issue 的关联标记。请填写 `Refs #编号`、`Closes #编号`、`Fixes #编号` 或 `Resolves #编号`。'; + } + + const failures = []; + + for (const issue_number of numbers) { + let issue; + + try { + ({ data: issue } = await github.rest.issues.get({ ...context.repo, issue_number })); + } catch (error) { + if (error.status !== 404 && error.status !== 410) { + throw error; + } + + failures.push(`#${issue_number} 不存在或不可访问`); + continue; + } + + const expectedUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/issues/${issue_number}`; + + if (issue.pull_request) { + failures.push(`#${issue_number} 是 PR,不是 issue`); + } else if (issue.html_url.toLowerCase() !== expectedUrl.toLowerCase()) { + failures.push(`#${issue_number} 已不属于本仓库`); + } else if (issue.state !== 'open') { + failures.push(`#${issue_number} 已关闭`); + } else if (Date.parse(issue.created_at) > Date.parse(pr.created_at)) { + failures.push(`#${issue_number} 晚于本 PR 创建,请先提 issue 再创建 PR`); + } else if ( + !PRIVILEGED_AUTHORS.has(issue.author_association) && + !issue.labels.some((label) => + TEMPLATE_LABELS.has(typeof label === 'string' ? label : label.name), + ) + ) { + // Match issue-template-guard.yml immediately, without racing its + // asynchronous close action. Its GITHUB_TOKEN cannot trigger us. + failures.push(`#${issue_number} 未通过 issue 模板检查`); + } else { + return null; + } + } + + return `没有有效的关联 issue:${failures.join(';')}。`; +} + +module.exports = async function guard({ github, context, core }) { + const params = { ...context.repo, pull_number: context.issue.number }; + const { data: pr } = await github.rest.pulls.get(params); + + if (pr.state !== 'open' || pr.merged) { + core.info('PR is no longer open; nothing to enforce.'); + return; + } + + const reason = await violation(pr, github, context); + + if (!reason) { + core.info(`PR #${pr.number} targets dev and references an eligible issue.`); + return; + } + + // An edit may have fixed the policy while its issue lookup was in flight. + const { data: current } = await github.rest.pulls.get(params); + + if ( + current.state !== 'open' || + current.merged || + current.body !== pr.body || + current.base.ref !== pr.base.ref + ) { + core.info('PR changed during validation; leaving it to the next event.'); + return; + } + + const commentParams = { ...context.repo, issue_number: pr.number }; + const comments = await github.paginate(github.rest.issues.listComments, { + ...commentParams, + per_page: 100, + }); + const previous = comments.find( + (comment) => + comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(COMMENT_MARKER), + ); + const body = [ + COMMENT_MARKER, + '此 PR 未满足贡献要求,现自动关闭。', + '', + reason, + '', + '请先通过 [GitHub 网页模板](' + + context.serverUrl + + '/' + + context.repo.owner + + '/' + + context.repo.repo + + '/issues/new/choose) 提交 issue;也可以认领已有的对应 issue。', + 'PR 必须指向 `dev`,并在正文引用至少一个先于 PR 创建、仍开放且通过模板检查的本仓库 issue。注释、代码示例和引用块中的标记不计入。', + '若已有符合要求的 issue,可修改目标分支或正文后重新打开 PR;若需新建 issue,请先完成 issue 再创建新的 PR。', + ].join('\n'); + + if (previous) { + if (previous.body !== body) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body }); + } + } else { + await github.rest.issues.createComment({ ...commentParams, body }); + } + + await github.rest.pulls.update({ ...params, state: 'closed' }); + core.setFailed(`Closed PR #${pr.number}: ${reason}`); +}; diff --git a/.github/scripts/pr-policy-guard.test.cjs b/.github/scripts/pr-policy-guard.test.cjs new file mode 100644 index 00000000..e3de4342 --- /dev/null +++ b/.github/scripts/pr-policy-guard.test.cjs @@ -0,0 +1,313 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const guard = require('./pr-policy-guard.cjs'); + +function fixture() { + const pr = { + number: 100, + state: 'open', + merged: false, + base: { ref: 'dev' }, + body: 'Refs #42', + created_at: '2026-11-01T10:00:00Z', + }; + const issue = { + number: 42, + state: 'open', + html_url: 'https://github.com/SnowLuma/SnowLuma/issues/42', + created_at: '2026-11-01T09:00:00Z', + author_association: 'NONE', + labels: [{ name: 'bug' }], + }; + const state = { + pr, + issue, + issues: new Map([[42, issue]]), + comments: [], + lookups: [], + mutations: [], + failures: [], + refresh: null, + requests: 0, + }; + const context = { + repo: { owner: 'SnowLuma', repo: 'SnowLuma' }, + issue: { number: 100 }, + serverUrl: 'https://github.com', + }; + const github = { + rest: { + pulls: { + get: async () => { + state.requests += 1; + return { + data: structuredClone(state.requests > 1 && state.refresh ? state.refresh : pr), + }; + }, + update: async (params) => state.mutations.push({ action: 'close', ...params }), + }, + issues: { + get: async ({ issue_number }) => { + state.lookups.push(issue_number); + const result = state.issues.get(issue_number); + + if (result instanceof Error) { + throw result; + } + + if (!result) { + throw Object.assign(new Error('Not found'), { status: 404 }); + } + + return { data: structuredClone(result) }; + }, + listComments: () => {}, + createComment: async (params) => state.mutations.push({ action: 'comment', ...params }), + updateComment: async (params) => + state.mutations.push({ action: 'edit-comment', ...params }), + }, + }, + paginate: async () => structuredClone(state.comments), + }; + const core = { + info: () => {}, + setFailed: (reason) => state.failures.push(reason), + }; + return { ...state, state, github, run: () => guard({ github, context, core }) }; +} + +function assertClosed(f, reason) { + assert.deepEqual( + f.mutations.map((entry) => entry.action), + ['comment', 'close'], + ); + assert.equal(f.mutations[1].pull_number, 100); + assert.equal(f.mutations[1].state, 'closed'); + assert.match(f.mutations[0].body, reason); + assert.equal(f.failures.length, 1); +} + +for (const body of [ + 'Refs #42', + 'refs: #42', + 'Ref #42', + 'Close #42', + 'Closes #42', + 'Closed #42', + 'Fix #42', + 'Fixes #42', + 'Fixed #42', + 'Resolve #42', + 'Resolves #42', + 'Resolved #42', + 'This change fixes #42.', + 'CLOSES: #42', + 'Refs SnowLuma/SnowLuma#42', + 'Refs snowluma/snowluma#42', + 'Refs https://github.com/SnowLuma/SnowLuma/issues/42', +]) { + test(`accepts a real issue reference: ${body}`, async () => { + const f = fixture(); + f.pr.body = body; + await f.run(); + assert.deepEqual(f.lookups, [42]); + assert.deepEqual(f.mutations, []); + assert.deepEqual(f.failures, []); + }); +} + +for (const body of [ + '', + null, + '#42', + 'prefixRefs #42', + 'Refs #0', + 'Refs #42abc', + 'Refs #42/other', + 'Refs #9007199254740993', + 'Refs other/repo#42', + 'Refs https://github.com/other/repo/issues/42', + 'Refs https://example.org/SnowLuma/SnowLuma/issues/42', + 'Refs https://github.com/SnowLuma/SnowLuma/pull/42', + 'Refs https://%', + '', + '\nold reason', + }); + await f.run(); + assert.deepEqual( + f.mutations.map((entry) => entry.action), + ['edit-comment', 'close'], + ); + assert.equal(f.mutations[0].comment_id, 7); +}); + +test('does not overwrite a contributor comment containing the guard marker', async () => { + const f = fixture(); + f.pr.body = ''; + f.comments.push({ + id: 7, + user: { login: 'contributor' }, + body: '', + }); + await f.run(); + assertClosed(f, /缺少本仓库 issue/); +}); + +test('does not close the PR if its explanation cannot be posted', async () => { + const f = fixture(); + f.pr.body = ''; + const error = Object.assign(new Error('Comment rejected'), { status: 403 }); + f.github.rest.issues.createComment = async () => { + throw error; + }; + await assert.rejects(f.run, error); + assert.deepEqual(f.mutations, []); +}); + +test('reuses an unchanged explanation without posting or editing another comment', async () => { + const first = fixture(); + first.pr.body = ''; + await first.run(); + + const reopened = fixture(); + reopened.pr.body = ''; + reopened.comments.push({ + id: 7, + user: { login: 'github-actions[bot]' }, + body: first.mutations[0].body, + }); + await reopened.run(); + assert.deepEqual( + reopened.mutations.map((entry) => entry.action), + ['close'], + ); +}); diff --git a/.github/workflows/main-pr-target-guard.yml b/.github/workflows/main-pr-target-guard.yml index 46ba8bd9..4545e840 100644 --- a/.github/workflows/main-pr-target-guard.yml +++ b/.github/workflows/main-pr-target-guard.yml @@ -1,20 +1,36 @@ -name: Main PR target guard +name: PR policy guard -# `main` is a pointer moved by Promote Dev to Main. Contributor and bot -# feature work lands on dev. A PR opened against main (like the GHCR -# fork PR) is what puts unique commits on main and fans the network graph. +# pull_request_target uses the default branch's trusted workflow and script. +# This policy becomes active after it reaches main through Promote. on: - pull_request: - branches: [main] + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review] permissions: contents: read + issues: read + pull-requests: write + +concurrency: + group: pr-policy-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - reject: + guard: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - name: Reject pull requests targeting main - run: | - echo '::error::Open this PR against dev, not main. main is updated only by Promote Dev to Main.' - exit 1 + - name: Read the trusted policy script + uses: actions/checkout@v6 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts/pr-policy-guard.cjs + sparse-checkout-cone-mode: false + + - name: Enforce target branch and linked issue + uses: actions/github-script@v7 + with: + script: | + const guard = require('./.github/scripts/pr-policy-guard.cjs'); + await guard({ github, context, core }); diff --git a/AGENTS.md b/AGENTS.md index 6f67c6b1..8dc639a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,10 @@ SnowLuma translates QQ client sessions into OneBot v11 actions and events, with ## Before making changes - Read [CONTRIBUTING.md](CONTRIBUTING.md) for contribution requirements, [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations, and [LICENSE](LICENSE) for contribution and redistribution terms. -- **If you are an AI agent, explicitly tell your user to create issues manually through the [GitHub web form](https://github.com/SnowLuma/SnowLuma/issues/new/choose). Issues created directly with a CLI will be automatically closed.** Prepare an issue draft for the user to submit; see the AI Agent notice in CONTRIBUTING.md. +- **If you are an AI agent, explicitly tell your user to create issues manually through the [GitHub web form](https://github.com/SnowLuma/SnowLuma/issues/new/choose). Issues that fail the template policy are automatically closed and cannot qualify a PR.** Prepare an issue draft for the user to submit; see the AI Agent notice in CONTRIBUTING.md. - Check `git status` and preserve unrelated work. Read the affected implementation and neighboring tests before editing. Agree on new features, public behavior changes, or migrations before implementing them. -- Target PRs at `dev`. `main` is the release pointer. Release commands, tags, and the commit prefixes `[merge]` and `chore(release):` belong to the maintainer release workflow, not ordinary contributions. +- Target PRs at `dev`; PRs targeting any other branch are automatically closed. `main` is the release pointer. Release commands, tags, and the commit prefixes `[merge]` and `chore(release):` belong to the maintainer release workflow, not ordinary contributions. +- Before opening a PR, create or identify the corresponding issue. PRs are optional after an issue is filed. Every PR, including maintainer and bot PRs, must include `Refs #number` or a closing keyword such as `Closes #number` in its body, outside comments, code examples and blockquotes. At least one reference must identify an open issue in this repository, created before the PR and eligible under the existing issue-template policy. PRs without a qualifying issue are automatically closed. See [the PR submission rules](CONTRIBUTING.md#4-推送并发起-pr) for supported forms and correction steps. ## Repository map @@ -61,7 +62,8 @@ Run commands from the repository root: ```sh pnpm typecheck # all workspace packages with a typecheck script pnpm lint -pnpm test # all workspace packages with a test script +pnpm test # workflow policy checks and workspace tests +pnpm test:workflows # isolated PR policy tests; no GitHub writes pnpm run build:all # release inputs and published packages pnpm test:core # Core only; not a substitute for workspace tests pnpm --filter @snowluma/onebot test # example: tests for the affected package @@ -117,6 +119,8 @@ After changing an action name, parameter, result description/schema, example or SDK breaking changes also update [CHANGELOG.md](packages/sdk/CHANGELOG.md) and [VERSIONING.md](packages/sdk/VERSIONING.md). WebUI changes follow its [interaction conventions](packages/webui/README.md). Keep user-facing documentation aligned with behavior. +The root [README.md](README.md) presents the project's purpose, overall capabilities, quick start and documentation entry points. Update it only when those overview details change; adding or fixing an individual feature does not justify adding a dedicated section there. Put feature-specific API usage, configuration, limitations and troubleshooting in the relevant package README or topic document, preferring existing documentation. + Edit sources rather than `dist` outputs. Native artifacts in `packages/runtime/native` are supplied build inputs; do not substitute arbitrary local binaries to make a build pass. Packaging changes must preserve the checks in [Core's build configuration](packages/core/vite.config.ts) and [the release layout validator](tools/check-release-layout.mjs). ## Handoff and instruction maintenance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9b8d0f8..3633e63b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ SnowLuma 处于开发早期,迭代速度很快,**接口与目录结构随时可能变化**。如果你打算做较大的改动,强烈建议先在 issue 或者社群里和维护者对齐一下方向,避免做了一半发现已经被另一条路替代。 +提交 PR 前,必须先通过 [GitHub 网页模板](https://github.com/SnowLuma/SnowLuma/issues/new/choose) 提交对应 issue,或认领已有的对应 issue。可以只提 issue;提交 PR 是可选的后续步骤。 + 不知道从哪里入手?以下几类贡献最容易被合并: - 修复你在使用中遇到的具体 bug,附最小复现步骤。 @@ -23,9 +25,9 @@ SnowLuma 处于开发早期,迭代速度很快,**接口与目录结构随时 如果你是 AI Agent,并准备为本仓库贡献代码、提交 PR 或提出 issue,请先明确告知你的使用者: -> **请通过 GitHub 网页端手动创建 issue。直接使用 CLI 创建的 issue 会被自动关闭。** +> **请通过 GitHub 网页端手动使用模板创建 issue。未通过模板检查的 issue 会被自动关闭,也不能用于满足 PR 的关联要求。** -你可以协助整理问题描述、复现步骤和相关日志,并将 issue 草稿交给用户,由用户在网页端完成提交。 +你可以协助整理问题描述、复现步骤和相关日志,并将 issue 草稿交给用户,由用户在网页端完成提交。后续若创建 PR,必须先有符合要求的 issue,并在 PR 正文填写关联标记。 --- @@ -98,7 +100,7 @@ pnpm dev:web # webui ### 1. 创建分支 -请基于 `dev` 创建分支,不是 `main`。`main` 是发布指针,只由维护者的 Promote 更新,PR 不要提到 `main`。 +请基于 `dev` 创建分支。`main` 是发布指针,只由维护者的 Promote 更新。**目标分支不是 `dev` 的 PR 会被自动关闭**,之后修改目标分支或重新打开 PR 也会触发检查。 ```bash git checkout dev @@ -161,7 +163,15 @@ git push origin fix/onebot-mention-encoding - 这个 PR 解决什么问题 / 实现什么功能。 - 主要的实现思路(如果有非显然的取舍)。 - 测试方式(你跑了什么、怎么验证有效)。 -- 关联的 issue(如有)。 +- 必填的 issue 关联标记,例如 `Refs #123` 或 `Closes #123`;也支持 GitHub 的 `Fixes`、`Resolves` 等关闭关键词。 + +关联标记必须写在 PR 正文中;HTML 注释、代码示例和引用块不计入。支持 `#123`、`SnowLuma/SnowLuma#123` 和本仓库 issue 的完整链接,前面均需带上述关键词。 + +自动检查要求至少一个关联对象是真实的本仓库 issue,先于 PR 创建、仍处于开放状态,并通过现有 issue 模板规则:带有 `bug` / `enhancement` 标签,或属于该规则已有的维护者身份豁免。不存在的编号、另一个 PR、已关闭或模板不合规的 issue 都不算。**所有 PR 都必须满足目标分支和 issue 关联要求,包括维护者与机器人提交的 PR。** + +不符合要求的 PR 会收到说明并自动关闭。若已有符合要求的 issue,可修改 PR 后重新打开;若需补建 issue,请先提交 issue,再创建新的 PR。接口故障或限流会让检查失败,不会被当成 PR 违规而自动关闭。 + +`Refs` 用于关联;`Closes` 等关键词在这里也用于关联检查。由于 PR 目标为 `dev`,合并到 `dev` 本身不会触发 GitHub 针对默认分支的 issue 自动关闭机制。 ### 5. Code Review 与合并 @@ -205,10 +215,12 @@ Prettier,并按需开启保存时格式化。 - 单测放在被测代码同包的 `tests/` 或同目录的 `*.test.ts`,按既有风格走。 - 测试要描述行为,不要测实现细节。`it('should call internal foo')` 这种是反例。 - 涉及 IO / 时序 / 外部进程的,用现有的 fake/stub 模式,参考 `packages/onebot/tests/` 中的 Bridge 测试替身。 +- PR 自动检查的隔离回归可运行 `pnpm test:workflows`;`pnpm test` 已包含此检查,不需要创建真实 PR 来测试自动关闭。 ### 文档 -- 用户/贡献者可见的行为变化,需要相应更新 README 或对应 package 下的文档。 +- **主 README 的定位**:根目录 [`README.md`](./README.md) 用于展示项目定位、整体能力、快速开始和文档入口,仅在这些总体信息发生变化时更新。不要因为新增或修复某个具体功能,就在主 README 增加该功能的专门说明。 +- **具体功能的说明位置**:接口用法、配置项、功能限制和排障说明写入对应 package 的 README 或专题文档,优先更新已有文档。用户/贡献者可见的行为变化,仍需同步维护对应文档。 - SDK 的破坏性变更必须更新 `packages/sdk/CHANGELOG.md` 和 `packages/sdk/VERSIONING.md`。 --- @@ -315,7 +327,7 @@ git push origin v1.7.0 # → 仅触发 release.yml - 看一下已有的 issue 和 PR,可能你的问题已经被讨论过。 - 去社群问一句(QQ / TG 链接见上方)。 -- 如果只是想确认"我这么改 OK 吗",欢迎开 draft PR,先把代码亮出来再讨论。 +- 有对应 issue 后,如果只是想确认"我这么改 OK 吗",欢迎开 draft PR,把代码亮出来再讨论。 感谢你愿意花时间贡献 SnowLuma —— 这个项目能走多远,取决于你们每一个人。❄️ diff --git a/package.json b/package.json index 0a58f2a7..4216fe08 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dev": "pnpm --filter @snowluma/core dev", "dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs", "dev:web": "pnpm --filter webui dev", - "test": "pnpm --filter=\"./packages/**\" -r --no-bail --if-present test", + "test": "pnpm test:workflows && pnpm --filter=\"./packages/**\" -r --no-bail --if-present test", + "test:workflows": "node --test .github/scripts/*.test.cjs", "test:core": "pnpm --filter @snowluma/core test", "test:watch": "pnpm --filter @snowluma/core test:watch", "test:common": "pnpm --filter @snowluma/common test",