Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
/docker-compose*.yaml @Zzackllack

# Application code and tests
/app/ @Zzackllack
/tests/ @Zzackllack
/apps/api/app/ @Zzackllack
/apps/api/tests/ @Zzackllack

# Cloudflare Worker
/src/ @Zzackllack
8 changes: 7 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ We follow the Conventional Commits specification: https://www.conventionalcommit

## Codeowners

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

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

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

External contributors who are not listed receive an informational pull request comment. The
CODEOWNERS check only blocks malformed rules or owner references that GitHub cannot resolve.

## Pull Request Process

1. Fork the repository and create a new branch.
Expand Down
137 changes: 69 additions & 68 deletions .github/workflows/codeowners-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
permissions:
contents: read
issues: write
pull-requests: write
pull-requests: read

concurrency:
group: codeowners-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
Expand All @@ -28,13 +28,12 @@ jobs:
- name: Validate CODEOWNERS coverage
id: validate
continue-on-error: true
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_MARKER: "<!-- anibridge-pr-codeowners -->"
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-validation -->"
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'];
Expand Down Expand Up @@ -141,58 +140,6 @@ jobs:
}
}

const globToRegex = (pattern) => {
let result = '^';
for (let i = 0; i < pattern.length; i += 1) {
const char = pattern[i];
const next = pattern[i + 1];
if (char === '*' && next === '*') {
result += '.*';
i += 1;
} else if (char === '*') {
result += '[^/]*';
} else if (char === '?') {
result += '[^/]';
} else if ('\\.[]{}()+-^$|'.includes(char)) {
result += `\\${char}`;
} else {
result += char;
}
}
result += '$';
return new RegExp(result);
};

const matches = (pattern, file) => {
if (pattern === '*') {
return true;
}

const normalized = pattern.replace(/^\/+/, '');
if (normalized.endsWith('/')) {
return file.startsWith(normalized);
}

return globToRegex(normalized).test(file);
};

const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: issue_number,
per_page: 100,
});

const candidatePaths = files
.filter((file) => ['added', 'renamed', 'copied'].includes(file.status))
.map((file) => file.filename);

const fallbackOnly = candidatePaths.filter((filename) => {
const matchedRules = rules.filter((rule) => matches(rule.pattern, filename));
const hasSpecificRule = matchedRules.some((rule) => rule.pattern !== '*');
return matchedRules.length > 0 && !hasSpecificRule;
});

const findings = [];
if (invalidLines.length > 0) {
findings.push(
Expand All @@ -211,15 +158,6 @@ jobs:
findings.push(` - \`${entry.owner}\` on line ${entry.lineNumber}`);
}
}
if (fallbackOnly.length > 0) {
findings.push(
'- The following added or renamed paths are only covered by the global `*` fallback:',
);
for (const filename of fallbackOnly) {
findings.push(` - \`${filename}\``);
}
}

if (findings.length === 0) {
return;
}
Expand All @@ -231,17 +169,17 @@ jobs:
'',
...findings,
'',
'If the fallback ownership is intentional, update `CODEOWNERS` to add an explicit rule or adjust this check.',
'This check only blocks malformed or unresolved ownership rules.',
].join('\n');

fs.writeFileSync('codeowners-comment.md', `${body}\n`, 'utf8');
throw new Error('CODEOWNERS review is required for this pull request.');

- name: Sync pull request CODEOWNERS comment
if: always()
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_MARKER: "<!-- anibridge-pr-codeowners -->"
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-validation -->"
with:
script: |
const fs = require('node:fs');
Expand Down Expand Up @@ -298,6 +236,69 @@ jobs:
);
}

- name: Sync new contributor ownership notice
if: always() && steps.validate.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_MARKER: "<!-- anibridge-pr-codeowners-contributor -->"
with:
script: |
const fs = require('node:fs');
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const marker = process.env.COMMENT_MARKER;
const author = context.payload.pull_request.user.login;
const association = context.payload.pull_request.author_association;
const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'];
const codeownersPath = candidates.find((candidate) => fs.existsSync(candidate));
const raw = codeownersPath ? fs.readFileSync(codeownersPath, 'utf8') : '';
const authorRef = `@${author}`.toLowerCase();
const listedAsOwner = raw
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith('#'))
.some((line) =>
line.trim().split(/\s+/).slice(1).some((entry) => entry.toLowerCase() === authorRef),
);
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: issue_number,
per_page: 100,
});
const changedCodeowners = files.some((file) => file.filename === codeownersPath);
const shouldComment =
!trustedAssociations.has(association) && !listedAsOwner && !changedCodeowners;
const body = [
marker,
`### CODEOWNERS note for @${author}`,
'',
`Thanks for contributing. You are not currently listed in \`${codeownersPath}\`.`,
'',
'You do **not** need to add yourself for this pull request. CODEOWNERS represents ongoing review responsibility, not a list of everyone who has contributed.',
'',
'A maintainer can add you later if you take recurring ownership of an area.',
].join('\n');

try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number, per_page: 100,
});
const existing = comments.find((comment) =>
comment.user?.type === 'Bot' && comment.body?.includes(marker),
);

if (shouldComment && existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else if (shouldComment) {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
} else if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
} catch (error) {
core.warning(`Skipping contributor ownership notice: ${error.message}`);
}

- name: Enforce CODEOWNERS validation
if: steps.validate.outcome == 'failure'
run: exit 1
49 changes: 32 additions & 17 deletions .github/workflows/pr-test-feedback.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: QA / PR Test Feedback
name: QA / PR Feedback

on:
workflow_run:
workflows:
- QA / Tests
- QA / Pylint Score
types:
- completed

Expand All @@ -17,8 +18,8 @@ concurrency:
cancel-in-progress: false

jobs:
sync-pytest-feedback:
name: Sync Pytest Feedback
sync-qa-feedback:
name: Sync QA Feedback
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
Expand All @@ -31,6 +32,14 @@ jobs:
core.setOutput('pr_number', pullRequest ? String(pullRequest.number) : '');
core.setOutput('conclusion', context.payload.workflow_run.conclusion || '');
core.setOutput('run_url', context.payload.workflow_run.html_url || '');
const workflowName = context.payload.workflow_run.name;
const isPylint = workflowName === 'QA / Pylint Score';
const isPytest = workflowName === 'QA / Tests';
if (!isPylint && !isPytest) {
throw new Error(`Unsupported QA workflow: ${workflowName}`);
}
core.setOutput('tool', isPylint ? 'Pylint' : 'Pytest');
core.setOutput('marker', `<!-- anibridge-pr-${isPylint ? 'pylint' : 'pytest'}-feedback -->`);

if (!pullRequest) {
core.setOutput('artifact_id', '');
Expand All @@ -39,7 +48,7 @@ jobs:

const { owner, repo } = context.repo;
const run_id = context.payload.workflow_run.id;
const artifactName = `pytest-output-pr-${pullRequest.number}`;
const artifactName = `${isPylint ? 'pylint' : 'pytest'}-output-pr-${pullRequest.number}`;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
Expand All @@ -49,48 +58,54 @@ jobs:
const artifact = artifacts.find((item) => item.name === artifactName);
core.setOutput('artifact_id', artifact ? String(artifact.id) : '');

- name: Download pytest output artifact
- name: Download QA output artifact
if: steps.context.outputs.conclusion == 'failure' && steps.context.outputs.artifact_id != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
ARTIFACT_ID: ${{ steps.context.outputs.artifact_id }}
TOOL: ${{ steps.context.outputs.tool }}
shell: bash
run: |
set -euo pipefail
gh api \
-H "Accept: application/vnd.github+json" \
"/repos/${REPO}/actions/artifacts/${ARTIFACT_ID}/zip" > pytest-output.zip
unzip -p pytest-output.zip pytest-output.txt > pytest-output.raw.txt
mv pytest-output.raw.txt pytest-output.txt
"/repos/${REPO}/actions/artifacts/${ARTIFACT_ID}/zip" > qa-output.zip
output_file="$(printf '%s' "${TOOL}" | tr '[:upper:]' '[:lower:]')-output.txt"
if ! unzip -p qa-output.zip "apps/api/${output_file}" > qa-output.txt; then
unzip -p qa-output.zip "${output_file}" > qa-output.txt
fi

- name: Build pull request feedback body
if: steps.context.outputs.pr_number != ''
env:
CONCLUSION: ${{ steps.context.outputs.conclusion }}
RUN_URL: ${{ steps.context.outputs.run_url }}
TOOL: ${{ steps.context.outputs.tool }}
MARKER: ${{ steps.context.outputs.marker }}
shell: bash
run: |
set -euo pipefail
marker='<!-- anibridge-pr-pytest-feedback -->'
{
echo "${marker}"
echo "${MARKER}"
echo "### ${TOOL} check ${CONCLUSION}"
echo
if [ "${CONCLUSION}" = "failure" ]; then
echo "Pytest failed for this pull request."
echo "${TOOL} failed for this pull request. The relevant output is included below."
else
echo "Pytest passed for this pull request."
echo "${TOOL} passed for this pull request."
fi
echo
echo "Run details: ${RUN_URL}"
if [ "${CONCLUSION}" = "failure" ] && [ -s pytest-output.txt ]; then
echo "[Open the full workflow run](${RUN_URL})"
if [ "${CONCLUSION}" = "failure" ] && [ -s qa-output.txt ]; then
echo
echo "<details><summary>Pytest output</summary>"
echo "<details><summary>${TOOL} output</summary>"
echo
echo '```text'
python - <<'PY'
from pathlib import Path

output = Path("pytest-output.txt").read_text(encoding="utf-8", errors="replace")
output = Path("qa-output.txt").read_text(encoding="utf-8", errors="replace")
max_chars = 50000
if len(output) > max_chars:
print("(truncated to the last 50000 characters)")
Expand All @@ -108,7 +123,7 @@ jobs:
if: steps.context.outputs.pr_number != ''
uses: actions/github-script@v9
env:
COMMENT_MARKER: "<!-- anibridge-pr-pytest-feedback -->"
COMMENT_MARKER: ${{ steps.context.outputs.marker }}
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
CONCLUSION: ${{ steps.context.outputs.conclusion }}
with:
Expand Down
Loading