ci: enforce dev targets and issue references for pull requests

Explain policy violations before closing pull requests, validate linked issues, and cover the guard with isolated tests. Align contributor guidance and keep feature details out of the root README.
This commit is contained in:
DrSmoothl
2026-11-01 21:09:48 +08:00
parent 24ec8a7eba
commit f79187f36a
7 changed files with 577 additions and 22 deletions
+6 -1
View File
@@ -2,7 +2,11 @@
## 关联 Issue
<!-- 例如 Closes #123。若无关联 issue,请说明这个 PR 解决什么问题、为何现在做。 -->
<!-- 必填:在本段填写 Refs #编号 或 Closes #编号(也支持 Fixes / Resolves)。
必须关联至少一个先于本 PR 创建、仍开放且通过模板检查的本仓库 issue。
先通过 GitHub 网页模板提交 issue,或认领已有对应 issue,再可选地提交 PR。
请把关联标记写在本注释之外,不要只保留示例或只填写编号。
目标分支不是 dev,或缺少有效关联 issue 的 PR 会被自动关闭。 -->
## 变更说明
@@ -10,6 +14,7 @@
## 提交前检查
- [ ] 目标分支为 `dev`,正文已关联符合上述要求的 issue
- [ ] 本 PR 聚焦单一目标,未夹带无关改动
- [ ] 已通过 `pnpm lint` 与 `pnpm typecheck`
- [ ] 已补充/更新相关测试,且 `pnpm test` 通过
+204
View File
@@ -0,0 +1,204 @@
const COMMENT_MARKER = '<!-- snowluma-pr-policy-guard -->';
const TEMPLATE_LABELS = new Set(['bug', 'enhancement']);
const PRIVILEGED_AUTHORS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
function prose(body) {
let fence = null;
return (body || '')
.replace(/<!--[\s\S]*?(?:-->|$)/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}`);
};
+313
View File
@@ -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://%',
'<!-- Closes #42 -->',
'<!-- Closes #42',
'`Refs #42`',
'```text\nRefs #42\n```',
'~~~\nRefs #42\n~~~',
'```\nRefs #42',
'> Closes #42',
' Refs #42',
'\tRefs #42',
]) {
test(`does not treat examples or invalid references as an issue: ${JSON.stringify(body)}`, async () => {
const f = fixture();
f.pr.body = body;
await f.run();
assert.deepEqual(f.lookups, []);
assertClosed(f, /缺少本仓库 issue/);
});
}
for (const base of ['main', 'release/v2', 'feature/example']) {
test(`closes a PR targeting ${base} before looking up issues`, async () => {
const f = fixture();
f.pr.base.ref = base;
await f.run();
assert.deepEqual(f.lookups, []);
assertClosed(f, /目标分支必须是 `dev`/);
});
}
for (const [patch, reason] of [
[{ pull_request: {} }, /是 PR,不是 issue/],
[{ state: 'closed', state_reason: 'not_planned' }, /已关闭/],
[{ state: 'closed', state_reason: 'completed' }, /已关闭/],
[{ html_url: 'https://github.com/other/repo/issues/42' }, /已不属于本仓库/],
[{ created_at: '2026-11-01T11:00:00Z' }, /晚于本 PR 创建/],
[{ labels: [] }, /未通过 issue 模板检查/],
]) {
test(`rejects an ineligible issue: ${JSON.stringify(patch)}`, async () => {
const f = fixture();
Object.assign(f.issue, patch);
await f.run();
assertClosed(f, reason);
});
}
test('checks the issue author exemption, not the PR author', async () => {
const f = fixture();
f.pr.author_association = 'OWNER';
f.issue.labels = [];
await f.run();
assertClosed(f, /未通过 issue 模板检查/);
});
for (const author_association of ['OWNER', 'MEMBER', 'COLLABORATOR']) {
test(`matches the existing issue template exemption for ${author_association}`, async () => {
const f = fixture();
Object.assign(f.issue, { author_association, labels: [] });
await f.run();
assert.deepEqual(f.mutations, []);
assert.deepEqual(f.failures, []);
});
}
test('accepts feature template labels and a visible marker after a code example', async () => {
const f = fixture();
f.issue.labels = ['enhancement'];
f.pr.body = '```text\nRefs #999\n```\nRefs #42';
await f.run();
assert.deepEqual(f.lookups, [42]);
assert.deepEqual(f.mutations, []);
});
test('deduplicates references and accepts at least one eligible issue', async () => {
const f = fixture();
f.pr.body = 'Refs #404\nCloses #404\nRefs #42';
await f.run();
assert.deepEqual(f.lookups, [404, 42]);
assert.deepEqual(f.mutations, []);
});
for (const status of [404, 410]) {
test(`rejects a missing or deleted issue (${status})`, async () => {
const f = fixture();
f.issues.set(42, Object.assign(new Error('Unavailable'), { status }));
await f.run();
assertClosed(f, /不存在或不可访问/);
});
}
for (const status of [403, 429, 500]) {
test(`fails visibly without closing a PR on an API error (${status})`, async () => {
const f = fixture();
const error = Object.assign(new Error('API unavailable'), { status });
f.issues.set(42, error);
await assert.rejects(f.run, error);
assert.deepEqual(f.mutations, []);
});
}
test('does not close a PR whose author fixed the body during validation', async () => {
const f = fixture();
f.pr.body = '';
f.state.refresh = { ...f.pr, body: 'Refs #42' };
await f.run();
assert.deepEqual(f.mutations, []);
assert.deepEqual(f.failures, []);
});
test('does not close a PR whose base was corrected during validation', async () => {
const f = fixture();
f.pr.base.ref = 'main';
f.state.refresh = { ...f.pr, base: { ref: 'dev' } };
await f.run();
assert.deepEqual(f.mutations, []);
});
test('leaves closed and merged PRs alone', async () => {
const f = fixture();
Object.assign(f.pr, { state: 'closed', merged: true });
await f.run();
assert.deepEqual(f.lookups, []);
assert.deepEqual(f.mutations, []);
});
test('updates its own previous rejection comment when a PR is reopened', async () => {
const f = fixture();
f.pr.body = '';
f.comments.push({
id: 7,
user: { login: 'github-actions[bot]' },
body: '<!-- snowluma-pr-policy-guard -->\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: '<!-- snowluma-pr-policy-guard -->',
});
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'],
);
});
+27 -11
View File
@@ -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 });
+7 -3
View File
@@ -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
+18 -6
View File
@@ -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 —— 这个项目能走多远,取决于你们每一个人。❄️
+2 -1
View File
@@ -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",