-
Notifications
You must be signed in to change notification settings - Fork 173
182 lines (168 loc) · 7.67 KB
/
Copy pathpr-governance.yml
File metadata and controls
182 lines (168 loc) · 7.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
name: PR Governance
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review]
permissions:
pull-requests: write
contents: read
concurrency:
group: pr-governance-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
governance:
name: Check PR compliance
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.draft && github.event.pull_request.state == 'open' }}
steps:
- uses: actions/github-script@v8
env:
DRAFT_PAT: ${{ secrets.PR_GOVERNANCE_PAT }}
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
const MARKER = '<!-- pr-governance-report -->';
// PR(如 dependabot)与维护者显式豁免的 PR 跳过检查。
const labels = pr.labels.map((l) => l.name);
if (pr.user.type === 'Bot' || labels.includes('governance-exempt')) {
core.info('Bot PR or governance-exempt label present, skipping governance checks.');
return;
}
const problems = [];
// 1) 必须关联 issue:优先看 GitHub 解析出的关闭引用,正则兜底
const gql = await github.graphql(
`query ($owner: String!, $repo: String!, $num: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $num) {
closingIssuesReferences(first: 1) { totalCount }
}
}
}`,
{ owner, repo, num: pr.number },
);
const linkedByGithub =
gql.repository.pullRequest.closingIssuesReferences.totalCount > 0;
const linkedByText =
/(clos(?:e|es|ed)|fix(?:es|ed)?|resolv(?:e|es|ed))\s*:?\s+(?:[\w.-]+\/[\w.-]+)?#\d+/i.test(body);
if (!linkedByGithub && !linkedByText) {
problems.push(
'**No linked issue**: the PR body must contain `Closes #123` / `Fixes #123` / `Resolves #123`. ' +
'This project requires an issue before a PR — see the [contribution guidelines](https://github.com/' +
`${owner}/${repo}/blob/main/.github/CONTRIBUTING.md).`,
);
}
// 2) UI 改动必须附截图/预览:改动文件命中前端路径,而正文没有图片即视为缺失。
const UI_PATHS = [
'crates/agent-gui/src/',
'crates/agent-gateway/web/src/',
];
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
const uiTouched = files.some((f) =>
UI_PATHS.some((p) => f.filename.startsWith(p)),
);
const hasImage =
/!\[[^\]]*\]\([^)]+\)|<img\s|user-attachments\/assets/i.test(body);
if (uiTouched && !hasImage) {
problems.push(
'**UI change without screenshots**: this PR modifies frontend code. Please add before/after screenshots or a recording under "Screenshots / preview" in the PR body.',
);
}
// 3) 必须与基线无冲突。mergeable 由 GitHub 异步计算,null 表示尚未算完,轮询等待。
let mergeable = pr.mergeable ?? null;
for (let i = 0; i < 6 && mergeable === null; i++) {
await new Promise((r) => setTimeout(r, 5000));
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
mergeable = data.mergeable;
}
if (mergeable === false) {
problems.push(
'**Merge conflicts with the target branch**: merge or rebase the latest base branch and resolve conflicts on your branch before requesting review. Maintainers do not resolve conflicts for you.',
);
}
// 汇总:不合规先尝试转 draft,再按实际结果生成报告,避免文案与真实状态不符。
const passed = problems.length === 0;
// 转失败(未配 PAT / token 失效)只警告:下面的 setFailed 会把检查置红,
// 配合 main 的 required status check 仍能阻止合并,治理不会失效。
let drafted = false;
if (!passed) {
try {
const pat = process.env.DRAFT_PAT;
if (!pat) throw new Error('PR_GOVERNANCE_PAT is not configured');
// 不走 github.graphql:octokit 的 auth 钩子会强制覆盖 authorization 头,传入的 PAT 会被 GITHUB_TOKEN 顶掉,而该 mutation 不接受 integration token。
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
authorization: `Bearer ${pat}`,
'content-type': 'application/json',
},
body: JSON.stringify({
query: `mutation ($id: ID!) {
convertPullRequestToDraft(input: { pullRequestId: $id }) {
pullRequest { isDraft }
}
}`,
variables: { id: pr.node_id },
}),
});
const out = await res.json();
if (!res.ok || out.errors?.length) {
throw new Error(out.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`);
}
drafted = true;
} catch (e) {
core.warning(`Failed to convert to draft (${e.message}); the check is still red and merge blocking is unaffected.`);
}
}
const report = passed
? `${MARKER}\n**PR governance checks passed.** Awaiting human review.`
: [
MARKER,
drafted
? '**PR governance checks failed — this PR has been converted to draft.**'
: '**PR governance checks failed.**',
'',
...problems.map((p) => `- ${p}`),
'',
drafted
? 'Fix the items above, then click **Ready for review** to re-run the checks.'
: 'Fix the items above — pushing new commits or editing the PR body will re-run the checks.',
].join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(MARKER));
if (existing) {
// 结果没变化就不重复编辑,避免 timeline 噪音。
if (existing.body !== report) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: report,
});
}
} else if (!passed) {
// 首次即通过的 PR 不发评论,保持安静。
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: report,
});
}
if (!passed) {
core.setFailed(`PR governance checks failed:\n- ${problems.join('\n- ')}`);
}