SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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}`);
|
||||
};
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user