Skip to content

Commit aef0c6a

Browse files
committed
ci: add PR quality gate workflow
1 parent c7a8e6f commit aef0c6a

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: PR Quality Gate
2+
3+
on:
4+
pull_request:
5+
branches: [develop]
6+
types: [opened, edited, synchronize, ready_for_review]
7+
8+
permissions:
9+
issues: write
10+
pull-requests: write
11+
12+
jobs:
13+
review:
14+
if: ${{ github.actor != 'dependabot[bot]' }}
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v3
18+
- name: Verify Copilot instructions
19+
run: test -s .github/copilot-instructions.md
20+
- name: Run expertise standard checks
21+
uses: actions/github-script@v7
22+
with:
23+
script: |
24+
const pr = context.payload.pull_request;
25+
const owner = context.repo.owner;
26+
const repo = context.repo.repo;
27+
// Fetch fresh PR data + files
28+
const { data: prData } = await github.rest.pulls.get({
29+
owner, repo, pull_number: pr.number
30+
});
31+
// Sum changed lines (additions + deletions)
32+
const totalChanged = prData.additions + prData.deletions;
33+
// Pull files for basic heuristics (e.g., tests touched?)
34+
const files = await github.paginate(
35+
github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number }
36+
);
37+
const commits = await github.paginate(
38+
github.rest.pulls.listCommits, { owner, repo, pull_number: pr.number }
39+
);
40+
const extsCode = ['.js','.ts','.tsx','.jsx','.py','.rb','.go','.rs','.java','.kt','.cs','.php','.c','.cc','.cpp','.m','.mm','.swift','.scala','.sh','.yml','.yaml','.json','.toml'];
41+
const extsTests = ['.spec.','.test.','/tests/','/__tests__/'];
42+
const codeTouched = files.some(f =>
43+
extsCode.some(ext => f.filename.includes(ext)));
44+
const testsTouched = files.some(f =>
45+
extsTests.some(tok => f.filename.includes(tok)));
46+
// 1) Scope ≤ 300 lines (from GitHub blog checklist)
47+
const scopeOK = totalChanged <= 300;
48+
// 2) Title and commits follow type: description (verb + object)
49+
const title = prData.title.trim();
50+
const types = ['feat','fix','docs','refactor','test','chore','ci','build','perf','style'];
51+
const naming = `^(${types.join('|')}):\\s+[A-Z][^\\s]*\\s+.+`;
52+
const titleOK = new RegExp(naming).test(title);
53+
const commitsOK = commits.every(c => new RegExp(naming).test(c.commit.message.split('\\n')[0]));
54+
// 3) Description “why now?” + links to issue
55+
const body = (prData.body || '').trim();
56+
const hasIssueLink = /#[0-9]+|https?:\/\/github\.com\/.+\/issues\/[0-9]+/i.test(body);
57+
const mentionsWhy = /\bwhy\b|\bbecause\b|\brationale\b|\bcontext\b/i.test(body);
58+
const descOK = body.length >= 50 && (mentionsWhy || hasIssueLink);
59+
// 4) BREAKING change highlighted
60+
const breakingFlagPresent = /\*\*?BREAKING\*\*?|⚠️\s*BREAKING|BREAKING CHANGE/i.test(title) || /\*\*?BREAKING\*\*?|⚠️\s*BREAKING|BREAKING CHANGE/i.test(body);
61+
// Heuristic: if "breaking" appears anywhere, require emphasis flag; otherwise pass.
62+
const containsBreakingWord = /\bbreaking\b/i.test(title) || /\bbreaking\b/i.test(body);
63+
const breakingOK = containsBreakingWord ? breakingFlagPresent : true;
64+
// 5) Request specific feedback
65+
const feedbackOK = /\b(feedback|review focus|please focus|looking for|need input)\b/i.test(body);
66+
// Soft hint: if code changed but no tests changed, nudge (not blocking per article)
67+
const testsHint = codeTouched && !testsTouched;
68+
// Build result table
69+
function row(name, ok, hint='') {
70+
const status = ok ? '✅' : '❌';
71+
const extra = hint ? ` — ${hint}` : '';
72+
return `| ${status} | ${name}${extra} |`;
73+
}
74+
const report = [
75+
`### PR Quality Gate — AI-Era Expertise Standard`,
76+
`This automated review checks your PR against the five items GitHub recommends for high-quality, human-in-the-loop reviews.`,
77+
``,
78+
`| Pass | Check |`,
79+
`|:----:|:------|`,
80+
row(`Scope ≤ 300 changed lines (current: ${totalChanged})`, scopeOK, scopeOK ? '' : 'Consider splitting into smaller PRs (stacking).'),
81+
row(`Title and commits use type: description (verb + object)`, titleOK && commitsOK),
82+
row(`Description answers "why now?" and links an issue`, descOK, hasIssueLink ? '' : 'Add a linked issue (#123) or URL.'),
83+
row(`Highlight breaking changes with **BREAKING** or ⚠️ BREAKING`, breakingOK, containsBreakingWord && !breakingFlagPresent ? 'Add explicit BREAKING flag.' : ''),
84+
row(`Request specific feedback (e.g., "Concurrency strategy OK?")`, feedbackOK),
85+
``,
86+
testsHint ? `> ℹ️ Heads-up: Code changed but tests weren’t touched. The blog suggests reviewers read tests first—consider adding or updating tests for clarity.` : ``,
87+
``,
88+
`_This gate is derived from GitHub’s “Why developer expertise matters more than ever in the age of AI.”_`
89+
].filter(Boolean).join('\n');
90+
// Determine blocking result (fail if any required check fails)
91+
const failures = [];
92+
if (!scopeOK) failures.push('Scope > 300 lines');
93+
if (!titleOK || !commitsOK) failures.push('Naming format invalid');
94+
if (!descOK) failures.push('Description lacks why/issue link');
95+
if (!breakingOK) failures.push('Missing explicit BREAKING flag');
96+
if (!feedbackOK) failures.push('No specific feedback requested');
97+
const sameRepo = pr.head.repo.full_name === `${owner}/${repo}`;
98+
if (sameRepo) {
99+
try {
100+
// Upsert a single sticky comment
101+
const bot = (await github.rest.users.getAuthenticated()).data.login;
102+
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number });
103+
const existing = comments.find(c => c.user?.login === bot && /PR Quality Gate — AI-Era/.test(c.body || ''));
104+
if (existing) {
105+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: report });
106+
} else {
107+
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: report });
108+
}
109+
// Add labels for visibility
110+
const addLabel = async (name) => {
111+
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [name] });
112+
};
113+
const removeLabel = async (name) => {
114+
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name });
115+
};
116+
if (failures.length) {
117+
await addLabel('needs-quality-fixes');
118+
} else {
119+
await removeLabel('needs-quality-fixes');
120+
await addLabel('quality-checked');
121+
}
122+
} catch (error) {
123+
if (error.message && error.message.includes('Resource not accessible by integration')) {
124+
core.warning('Skipping comment and label updates due to insufficient permissions.');
125+
} else {
126+
throw error;
127+
}
128+
}
129+
} else {
130+
core.warning('PR originates from a fork; skipping comment and label updates.');
131+
}
132+
// Fail the job if there are blocking issues
133+
if (failures.length) {
134+
core.setFailed('PR failed the expertise standard: ' + failures.join(', '));
135+
} else {
136+
core.info('PR passes the expertise standard.');
137+
}
138+

0 commit comments

Comments
 (0)