Skip to content

Commit d5b8f01

Browse files
authored
Merge pull request #134 from Zzackllack/better-ci-cd
ci: enhance CI workflows with CODEOWNERS and feedback
2 parents e01bfb7 + 2236808 commit d5b8f01

12 files changed

Lines changed: 172 additions & 676 deletions

.github/CODEOWNERS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
/docker-compose*.yaml @Zzackllack
1919

2020
# Application code and tests
21-
/app/ @Zzackllack
22-
/tests/ @Zzackllack
21+
/apps/api/app/ @Zzackllack
22+
/apps/api/tests/ @Zzackllack
2323

2424
# Cloudflare Worker
2525
/src/ @Zzackllack

.github/CONTRIBUTING.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ We follow the Conventional Commits specification: https://www.conventionalcommit
7878

7979
## Codeowners
8080

81-
The codeowners for this repository are listed in the [CODEOWNERS](/.github/CODEOWNERS) file. Please update it as necessary when making changes to the codebase.
81+
The codeowners for this repository are listed in the [CODEOWNERS](/.github/CODEOWNERS) file.
82+
CODEOWNERS records ongoing review responsibility; it is not a list of every contributor.
83+
Do not add yourself only because you opened a pull request. Maintainers may add recurring
84+
contributors when they take ownership of an area.
8285

8386
Conventions and tips you should follow when editing the CODEOWNERS file:
8487

@@ -87,6 +90,9 @@ Conventions and tips you should follow when editing the CODEOWNERS file:
8790
- Only `CODEOWNERS`, `.github/CODEOWNERS`, or `docs/CODEOWNERS` are recognized by GitHub.
8891
- Use @org/team for teams.
8992

93+
External contributors who are not listed receive an informational pull request comment. The
94+
CODEOWNERS check only blocks malformed rules or owner references that GitHub cannot resolve.
95+
9096
## Pull Request Process
9197

9298
1. Fork the repository and create a new branch.

.github/workflows/codeowners-review.yml

Lines changed: 69 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ on:
1111
permissions:
1212
contents: read
1313
issues: write
14-
pull-requests: write
14+
pull-requests: read
1515

1616
concurrency:
1717
group: codeowners-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -28,13 +28,12 @@ jobs:
2828
- name: Validate CODEOWNERS coverage
2929
id: validate
3030
continue-on-error: true
31-
uses: actions/github-script@v9
31+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
3232
env:
33-
COMMENT_MARKER: "<!-- anibridge-pr-codeowners -->"
33+
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-validation -->"
3434
with:
3535
script: |
3636
const fs = require('node:fs');
37-
const path = require('node:path');
3837
const { owner, repo } = context.repo;
3938
const issue_number = context.payload.pull_request.number;
4039
const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'];
@@ -141,58 +140,6 @@ jobs:
141140
}
142141
}
143142
144-
const globToRegex = (pattern) => {
145-
let result = '^';
146-
for (let i = 0; i < pattern.length; i += 1) {
147-
const char = pattern[i];
148-
const next = pattern[i + 1];
149-
if (char === '*' && next === '*') {
150-
result += '.*';
151-
i += 1;
152-
} else if (char === '*') {
153-
result += '[^/]*';
154-
} else if (char === '?') {
155-
result += '[^/]';
156-
} else if ('\\.[]{}()+-^$|'.includes(char)) {
157-
result += `\\${char}`;
158-
} else {
159-
result += char;
160-
}
161-
}
162-
result += '$';
163-
return new RegExp(result);
164-
};
165-
166-
const matches = (pattern, file) => {
167-
if (pattern === '*') {
168-
return true;
169-
}
170-
171-
const normalized = pattern.replace(/^\/+/, '');
172-
if (normalized.endsWith('/')) {
173-
return file.startsWith(normalized);
174-
}
175-
176-
return globToRegex(normalized).test(file);
177-
};
178-
179-
const files = await github.paginate(github.rest.pulls.listFiles, {
180-
owner,
181-
repo,
182-
pull_number: issue_number,
183-
per_page: 100,
184-
});
185-
186-
const candidatePaths = files
187-
.filter((file) => ['added', 'renamed', 'copied'].includes(file.status))
188-
.map((file) => file.filename);
189-
190-
const fallbackOnly = candidatePaths.filter((filename) => {
191-
const matchedRules = rules.filter((rule) => matches(rule.pattern, filename));
192-
const hasSpecificRule = matchedRules.some((rule) => rule.pattern !== '*');
193-
return matchedRules.length > 0 && !hasSpecificRule;
194-
});
195-
196143
const findings = [];
197144
if (invalidLines.length > 0) {
198145
findings.push(
@@ -211,15 +158,6 @@ jobs:
211158
findings.push(` - \`${entry.owner}\` on line ${entry.lineNumber}`);
212159
}
213160
}
214-
if (fallbackOnly.length > 0) {
215-
findings.push(
216-
'- The following added or renamed paths are only covered by the global `*` fallback:',
217-
);
218-
for (const filename of fallbackOnly) {
219-
findings.push(` - \`${filename}\``);
220-
}
221-
}
222-
223161
if (findings.length === 0) {
224162
return;
225163
}
@@ -231,17 +169,17 @@ jobs:
231169
'',
232170
...findings,
233171
'',
234-
'If the fallback ownership is intentional, update `CODEOWNERS` to add an explicit rule or adjust this check.',
172+
'This check only blocks malformed or unresolved ownership rules.',
235173
].join('\n');
236174
237175
fs.writeFileSync('codeowners-comment.md', `${body}\n`, 'utf8');
238176
throw new Error('CODEOWNERS review is required for this pull request.');
239177
240178
- name: Sync pull request CODEOWNERS comment
241179
if: always()
242-
uses: actions/github-script@v9
180+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
243181
env:
244-
COMMENT_MARKER: "<!-- anibridge-pr-codeowners -->"
182+
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-validation -->"
245183
with:
246184
script: |
247185
const fs = require('node:fs');
@@ -298,6 +236,69 @@ jobs:
298236
);
299237
}
300238
239+
- name: Sync new contributor ownership notice
240+
if: always() && steps.validate.outcome == 'success'
241+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
242+
env:
243+
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-contributor -->"
244+
with:
245+
script: |
246+
const fs = require('node:fs');
247+
const { owner, repo } = context.repo;
248+
const issue_number = context.payload.pull_request.number;
249+
const marker = process.env.COMMENT_MARKER;
250+
const author = context.payload.pull_request.user.login;
251+
const association = context.payload.pull_request.author_association;
252+
const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
253+
const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'];
254+
const codeownersPath = candidates.find((candidate) => fs.existsSync(candidate));
255+
const raw = codeownersPath ? fs.readFileSync(codeownersPath, 'utf8') : '';
256+
const authorRef = `@${author}`.toLowerCase();
257+
const listedAsOwner = raw
258+
.split(/\r?\n/)
259+
.filter((line) => line.trim() && !line.trim().startsWith('#'))
260+
.some((line) =>
261+
line.trim().split(/\s+/).slice(1).some((entry) => entry.toLowerCase() === authorRef),
262+
);
263+
const files = await github.paginate(github.rest.pulls.listFiles, {
264+
owner,
265+
repo,
266+
pull_number: issue_number,
267+
per_page: 100,
268+
});
269+
const changedCodeowners = files.some((file) => file.filename === codeownersPath);
270+
const shouldComment =
271+
!trustedAssociations.has(association) && !listedAsOwner && !changedCodeowners;
272+
const body = [
273+
marker,
274+
`### CODEOWNERS note for @${author}`,
275+
'',
276+
`Thanks for contributing. You are not currently listed in \`${codeownersPath}\`.`,
277+
'',
278+
'You do **not** need to add yourself for this pull request. CODEOWNERS represents ongoing review responsibility, not a list of everyone who has contributed.',
279+
'',
280+
'A maintainer can add you later if you take recurring ownership of an area.',
281+
].join('\n');
282+
283+
try {
284+
const comments = await github.paginate(github.rest.issues.listComments, {
285+
owner, repo, issue_number, per_page: 100,
286+
});
287+
const existing = comments.find((comment) =>
288+
comment.user?.type === 'Bot' && comment.body?.includes(marker),
289+
);
290+
291+
if (shouldComment && existing) {
292+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
293+
} else if (shouldComment) {
294+
await github.rest.issues.createComment({ owner, repo, issue_number, body });
295+
} else if (existing) {
296+
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
297+
}
298+
} catch (error) {
299+
core.warning(`Skipping contributor ownership notice: ${error.message}`);
300+
}
301+
301302
- name: Enforce CODEOWNERS validation
302303
if: steps.validate.outcome == 'failure'
303304
run: exit 1

.github/workflows/pr-test-feedback.yml

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
name: QA / PR Test Feedback
1+
name: QA / PR Feedback
22

33
on:
44
workflow_run:
55
workflows:
66
- QA / Tests
7+
- QA / Pylint Score
78
types:
89
- completed
910

@@ -17,8 +18,8 @@ concurrency:
1718
cancel-in-progress: false
1819

1920
jobs:
20-
sync-pytest-feedback:
21-
name: Sync Pytest Feedback
21+
sync-qa-feedback:
22+
name: Sync QA Feedback
2223
if: github.event.workflow_run.event == 'pull_request'
2324
runs-on: ubuntu-latest
2425
steps:
@@ -31,6 +32,14 @@ jobs:
3132
core.setOutput('pr_number', pullRequest ? String(pullRequest.number) : '');
3233
core.setOutput('conclusion', context.payload.workflow_run.conclusion || '');
3334
core.setOutput('run_url', context.payload.workflow_run.html_url || '');
35+
const workflowName = context.payload.workflow_run.name;
36+
const isPylint = workflowName === 'QA / Pylint Score';
37+
const isPytest = workflowName === 'QA / Tests';
38+
if (!isPylint && !isPytest) {
39+
throw new Error(`Unsupported QA workflow: ${workflowName}`);
40+
}
41+
core.setOutput('tool', isPylint ? 'Pylint' : 'Pytest');
42+
core.setOutput('marker', `<!-- anibridge-pr-${isPylint ? 'pylint' : 'pytest'}-feedback -->`);
3443
3544
if (!pullRequest) {
3645
core.setOutput('artifact_id', '');
@@ -39,7 +48,7 @@ jobs:
3948
4049
const { owner, repo } = context.repo;
4150
const run_id = context.payload.workflow_run.id;
42-
const artifactName = `pytest-output-pr-${pullRequest.number}`;
51+
const artifactName = `${isPylint ? 'pylint' : 'pytest'}-output-pr-${pullRequest.number}`;
4352
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
4453
owner,
4554
repo,
@@ -49,48 +58,54 @@ jobs:
4958
const artifact = artifacts.find((item) => item.name === artifactName);
5059
core.setOutput('artifact_id', artifact ? String(artifact.id) : '');
5160
52-
- name: Download pytest output artifact
61+
- name: Download QA output artifact
5362
if: steps.context.outputs.conclusion == 'failure' && steps.context.outputs.artifact_id != ''
5463
env:
5564
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5665
REPO: ${{ github.repository }}
5766
ARTIFACT_ID: ${{ steps.context.outputs.artifact_id }}
67+
TOOL: ${{ steps.context.outputs.tool }}
5868
shell: bash
5969
run: |
6070
set -euo pipefail
6171
gh api \
6272
-H "Accept: application/vnd.github+json" \
63-
"/repos/${REPO}/actions/artifacts/${ARTIFACT_ID}/zip" > pytest-output.zip
64-
unzip -p pytest-output.zip pytest-output.txt > pytest-output.raw.txt
65-
mv pytest-output.raw.txt pytest-output.txt
73+
"/repos/${REPO}/actions/artifacts/${ARTIFACT_ID}/zip" > qa-output.zip
74+
output_file="$(printf '%s' "${TOOL}" | tr '[:upper:]' '[:lower:]')-output.txt"
75+
if ! unzip -p qa-output.zip "apps/api/${output_file}" > qa-output.txt; then
76+
unzip -p qa-output.zip "${output_file}" > qa-output.txt
77+
fi
6678
6779
- name: Build pull request feedback body
6880
if: steps.context.outputs.pr_number != ''
6981
env:
7082
CONCLUSION: ${{ steps.context.outputs.conclusion }}
7183
RUN_URL: ${{ steps.context.outputs.run_url }}
84+
TOOL: ${{ steps.context.outputs.tool }}
85+
MARKER: ${{ steps.context.outputs.marker }}
7286
shell: bash
7387
run: |
7488
set -euo pipefail
75-
marker='<!-- anibridge-pr-pytest-feedback -->'
7689
{
77-
echo "${marker}"
90+
echo "${MARKER}"
91+
echo "### ${TOOL} check ${CONCLUSION}"
92+
echo
7893
if [ "${CONCLUSION}" = "failure" ]; then
79-
echo "Pytest failed for this pull request."
94+
echo "${TOOL} failed for this pull request. The relevant output is included below."
8095
else
81-
echo "Pytest passed for this pull request."
96+
echo "${TOOL} passed for this pull request."
8297
fi
8398
echo
84-
echo "Run details: ${RUN_URL}"
85-
if [ "${CONCLUSION}" = "failure" ] && [ -s pytest-output.txt ]; then
99+
echo "[Open the full workflow run](${RUN_URL})"
100+
if [ "${CONCLUSION}" = "failure" ] && [ -s qa-output.txt ]; then
86101
echo
87-
echo "<details><summary>Pytest output</summary>"
102+
echo "<details><summary>${TOOL} output</summary>"
88103
echo
89104
echo '```text'
90105
python - <<'PY'
91106
from pathlib import Path
92107
93-
output = Path("pytest-output.txt").read_text(encoding="utf-8", errors="replace")
108+
output = Path("qa-output.txt").read_text(encoding="utf-8", errors="replace")
94109
max_chars = 50000
95110
if len(output) > max_chars:
96111
print("(truncated to the last 50000 characters)")
@@ -108,7 +123,7 @@ jobs:
108123
if: steps.context.outputs.pr_number != ''
109124
uses: actions/github-script@v9
110125
env:
111-
COMMENT_MARKER: "<!-- anibridge-pr-pytest-feedback -->"
126+
COMMENT_MARKER: ${{ steps.context.outputs.marker }}
112127
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
113128
CONCLUSION: ${{ steps.context.outputs.conclusion }}
114129
with:

0 commit comments

Comments
 (0)