352 lines
14 KiB
YAML
352 lines
14 KiB
YAML
name: pr-standards
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, edited, synchronize]
|
|
|
|
jobs:
|
|
check-standards:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
steps:
|
|
- name: Check PR standards
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const pr = context.payload.pull_request;
|
|
const login = pr.user.login;
|
|
|
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
const prCreated = new Date(pr.created_at);
|
|
if (prCreated < cutoff) {
|
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
return;
|
|
}
|
|
|
|
// Check if author is a team member or bot
|
|
if (login === 'opencode-agent[bot]') return;
|
|
const { data: file } = await github.rest.repos.getContent({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
path: '.github/TEAM_MEMBERS',
|
|
ref: 'dev'
|
|
});
|
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
|
if (members.includes(login)) {
|
|
console.log(`Skipping: ${login} is a team member`);
|
|
return;
|
|
}
|
|
|
|
const title = pr.title;
|
|
|
|
async function addLabel(label) {
|
|
await github.rest.issues.addLabels({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
labels: [label]
|
|
});
|
|
}
|
|
|
|
async function removeLabel(label) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
name: label
|
|
});
|
|
} catch (e) {
|
|
// Label wasn't present, ignore
|
|
}
|
|
}
|
|
|
|
async function comment(marker, body) {
|
|
const markerText = `<!-- pr-standards:${marker} -->`;
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number
|
|
});
|
|
|
|
const existing = comments.find(c => c.body.includes(markerText));
|
|
if (existing) return;
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
body: markerText + '\n' + body
|
|
});
|
|
}
|
|
|
|
// Step 1: Check title format
|
|
// Matches: feat:, feat(scope):, feat (scope):, etc.
|
|
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
|
|
const hasValidTitle = titlePattern.test(title);
|
|
|
|
if (!hasValidTitle) {
|
|
await addLabel('needs:title');
|
|
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
|
|
|
|
Please update it to start with one of:
|
|
- \`feat:\` or \`feat(scope):\` new feature
|
|
- \`fix:\` or \`fix(scope):\` bug fix
|
|
- \`docs:\` or \`docs(scope):\` documentation changes
|
|
- \`chore:\` or \`chore(scope):\` maintenance tasks
|
|
- \`refactor:\` or \`refactor(scope):\` code refactoring
|
|
- \`test:\` or \`test(scope):\` adding or updating tests
|
|
|
|
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
|
|
|
|
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
|
|
return;
|
|
}
|
|
|
|
await removeLabel('needs:title');
|
|
|
|
// Step 2: Check for linked issue (skip for docs/refactor/feat PRs)
|
|
const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
|
if (skipIssueCheck) {
|
|
await removeLabel('needs:issue');
|
|
console.log('Skipping issue check for docs/refactor/feat PR');
|
|
return;
|
|
}
|
|
const query = `
|
|
query($owner: String!, $repo: String!, $number: Int!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $number) {
|
|
closingIssuesReferences(first: 1) {
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await github.graphql(query, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
number: pr.number
|
|
});
|
|
|
|
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
|
|
|
if (linkedIssues === 0) {
|
|
await addLabel('needs:issue');
|
|
await comment('issue', `Thanks for your contribution!
|
|
|
|
This PR doesn't have a linked issue. All PRs must reference an existing issue.
|
|
|
|
Please:
|
|
1. Open an issue describing the bug/feature (if one doesn't exist)
|
|
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
|
|
|
|
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
|
|
return;
|
|
}
|
|
|
|
await removeLabel('needs:issue');
|
|
console.log('PR meets all standards');
|
|
|
|
check-compliance:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
steps:
|
|
- name: Check PR template compliance
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const pr = context.payload.pull_request;
|
|
const login = pr.user.login;
|
|
|
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
const prCreated = new Date(pr.created_at);
|
|
if (prCreated < cutoff) {
|
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
return;
|
|
}
|
|
|
|
// Check if author is a team member or bot
|
|
if (login === 'opencode-agent[bot]') return;
|
|
const { data: file } = await github.rest.repos.getContent({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
path: '.github/TEAM_MEMBERS',
|
|
ref: 'dev'
|
|
});
|
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
|
if (members.includes(login)) {
|
|
console.log(`Skipping: ${login} is a team member`);
|
|
return;
|
|
}
|
|
|
|
const body = pr.body || '';
|
|
const title = pr.title;
|
|
const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
|
|
|
const issues = [];
|
|
|
|
// Check: template sections exist
|
|
const hasWhatSection = /### What does this PR do\?/.test(body);
|
|
const hasTypeSection = /### Type of change/.test(body);
|
|
const hasVerifySection = /### How did you verify your code works\?/.test(body);
|
|
const hasChecklistSection = /### Checklist/.test(body);
|
|
const hasIssueSection = /### Issue for this PR/.test(body);
|
|
|
|
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
|
|
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
|
|
}
|
|
|
|
// Check: "What does this PR do?" has real content (not just placeholder text)
|
|
if (hasWhatSection) {
|
|
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
|
|
const whatContent = whatMatch ? whatMatch[1].trim() : '';
|
|
const placeholder = 'Please provide a description of the issue';
|
|
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
|
|
if (!whatContent || onlyPlaceholder) {
|
|
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
|
|
}
|
|
}
|
|
|
|
// Check: at least one "Type of change" checkbox is checked
|
|
if (hasTypeSection) {
|
|
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
|
|
const typeContent = typeMatch ? typeMatch[1] : '';
|
|
const hasCheckedBox = /- \[x\]/i.test(typeContent);
|
|
if (!hasCheckedBox) {
|
|
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
|
|
}
|
|
}
|
|
|
|
// Check: issue reference (skip for docs/refactor/feat)
|
|
if (!isDocsRefactorOrFeat && hasIssueSection) {
|
|
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
|
const issueContent = issueMatch ? issueMatch[1].trim() : '';
|
|
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
|
if (!hasIssueRef) {
|
|
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
|
|
}
|
|
}
|
|
|
|
// Check: "How did you verify" has content
|
|
if (hasVerifySection) {
|
|
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
|
|
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
|
|
if (!verifyContent) {
|
|
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
|
|
}
|
|
}
|
|
|
|
// Check: checklist boxes are checked
|
|
if (hasChecklistSection) {
|
|
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
|
|
const checklistContent = checklistMatch ? checklistMatch[1] : '';
|
|
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
|
|
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
|
|
if (checked < 2) {
|
|
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
async function addLabel(label) {
|
|
await github.rest.issues.addLabels({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
labels: [label]
|
|
});
|
|
}
|
|
|
|
async function removeLabel(label) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
name: label
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
|
|
|
|
if (issues.length > 0) {
|
|
// Non-compliant
|
|
if (!hasComplianceLabel) {
|
|
await addLabel('needs:compliance');
|
|
}
|
|
|
|
const marker = '<!-- issue-compliance -->';
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number
|
|
});
|
|
const existing = comments.find(c => c.body.includes(marker));
|
|
|
|
const body_text = `${marker}
|
|
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
|
|
|
|
**What needs to be fixed:**
|
|
${issues.map(i => `- ${i}`).join('\n')}
|
|
|
|
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
|
|
|
|
If you believe this was flagged incorrectly, please let a maintainer know.`;
|
|
|
|
if (existing) {
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: existing.id,
|
|
body: body_text
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
body: body_text
|
|
});
|
|
}
|
|
|
|
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
|
|
} else if (hasComplianceLabel) {
|
|
// Was non-compliant, now fixed
|
|
await removeLabel('needs:compliance');
|
|
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number
|
|
});
|
|
const marker = '<!-- issue-compliance -->';
|
|
const existing = comments.find(c => c.body.includes(marker));
|
|
if (existing) {
|
|
await github.rest.issues.deleteComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: existing.id
|
|
});
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
|
|
});
|
|
|
|
console.log(`PR #${pr.number} is now compliant, label removed`);
|
|
} else {
|
|
console.log(`PR #${pr.number} is compliant`);
|
|
}
|