pr-standards.yml 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. name: pr-standards
  2. on:
  3. pull_request_target:
  4. types: [opened, edited, synchronize]
  5. jobs:
  6. check-standards:
  7. runs-on: ubuntu-latest
  8. permissions:
  9. contents: read
  10. pull-requests: write
  11. steps:
  12. - name: Check PR standards
  13. uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
  14. with:
  15. script: |
  16. const pr = context.payload.pull_request;
  17. const login = pr.user.login;
  18. // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
  19. const cutoff = new Date('2026-02-19T00:00:00Z');
  20. const prCreated = new Date(pr.created_at);
  21. if (prCreated < cutoff) {
  22. console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
  23. return;
  24. }
  25. // Check if author is a team member or bot
  26. if (login === 'opencode-agent[bot]') return;
  27. const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
  28. if (teamAssociations.includes(pr.author_association)) {
  29. console.log(`Skipping: ${login} has author association ${pr.author_association}`);
  30. return;
  31. }
  32. const title = pr.title;
  33. async function addLabel(label) {
  34. await github.rest.issues.addLabels({
  35. owner: context.repo.owner,
  36. repo: context.repo.repo,
  37. issue_number: pr.number,
  38. labels: [label]
  39. });
  40. }
  41. async function removeLabel(label) {
  42. try {
  43. await github.rest.issues.removeLabel({
  44. owner: context.repo.owner,
  45. repo: context.repo.repo,
  46. issue_number: pr.number,
  47. name: label
  48. });
  49. } catch (e) {
  50. // Label wasn't present, ignore
  51. }
  52. }
  53. async function comment(marker, body) {
  54. const markerText = `<!-- pr-standards:${marker} -->`;
  55. const { data: comments } = await github.rest.issues.listComments({
  56. owner: context.repo.owner,
  57. repo: context.repo.repo,
  58. issue_number: pr.number
  59. });
  60. const existing = comments.find(c => c.body.includes(markerText));
  61. if (existing) return;
  62. await github.rest.issues.createComment({
  63. owner: context.repo.owner,
  64. repo: context.repo.repo,
  65. issue_number: pr.number,
  66. body: markerText + '\n' + body
  67. });
  68. }
  69. // Step 1: Check title format
  70. // Matches: feat:, feat(scope):, feat (scope):, etc.
  71. const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
  72. const hasValidTitle = titlePattern.test(title);
  73. if (!hasValidTitle) {
  74. await addLabel('needs:title');
  75. await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
  76. Please update it to start with one of:
  77. - \`feat:\` or \`feat(scope):\` new feature
  78. - \`fix:\` or \`fix(scope):\` bug fix
  79. - \`docs:\` or \`docs(scope):\` documentation changes
  80. - \`chore:\` or \`chore(scope):\` maintenance tasks
  81. - \`refactor:\` or \`refactor(scope):\` code refactoring
  82. - \`test:\` or \`test(scope):\` adding or updating tests
  83. Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
  84. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
  85. return;
  86. }
  87. await removeLabel('needs:title');
  88. // Step 2: Check for linked issue (skip for docs/refactor/feat PRs)
  89. const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
  90. if (skipIssueCheck) {
  91. await removeLabel('needs:issue');
  92. console.log('Skipping issue check for docs/refactor/feat PR');
  93. return;
  94. }
  95. const query = `
  96. query($owner: String!, $repo: String!, $number: Int!) {
  97. repository(owner: $owner, name: $repo) {
  98. pullRequest(number: $number) {
  99. closingIssuesReferences(first: 1) {
  100. totalCount
  101. }
  102. }
  103. }
  104. }
  105. `;
  106. const result = await github.graphql(query, {
  107. owner: context.repo.owner,
  108. repo: context.repo.repo,
  109. number: pr.number
  110. });
  111. const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
  112. if (linkedIssues === 0) {
  113. await addLabel('needs:issue');
  114. await comment('issue', `Thanks for your contribution!
  115. This PR doesn't have a linked issue. All PRs must reference an existing issue.
  116. Please:
  117. 1. Open an issue describing the bug/feature (if one doesn't exist)
  118. 2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
  119. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
  120. return;
  121. }
  122. await removeLabel('needs:issue');
  123. console.log('PR meets all standards');
  124. check-compliance:
  125. runs-on: ubuntu-latest
  126. permissions:
  127. contents: read
  128. pull-requests: write
  129. steps:
  130. - name: Check PR template compliance
  131. uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
  132. with:
  133. script: |
  134. const pr = context.payload.pull_request;
  135. const login = pr.user.login;
  136. // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
  137. const cutoff = new Date('2026-02-19T00:00:00Z');
  138. const prCreated = new Date(pr.created_at);
  139. if (prCreated < cutoff) {
  140. console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
  141. return;
  142. }
  143. // Check if author is a team member or bot
  144. if (login === 'opencode-agent[bot]') return;
  145. const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
  146. if (teamAssociations.includes(pr.author_association)) {
  147. console.log(`Skipping: ${login} has author association ${pr.author_association}`);
  148. return;
  149. }
  150. const body = pr.body || '';
  151. const title = pr.title;
  152. const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
  153. const issues = [];
  154. // Check: template sections exist
  155. const hasWhatSection = /### What does this PR do\?/.test(body);
  156. const hasTypeSection = /### Type of change/.test(body);
  157. const hasVerifySection = /### How did you verify your code works\?/.test(body);
  158. const hasChecklistSection = /### Checklist/.test(body);
  159. const hasIssueSection = /### Issue for this PR/.test(body);
  160. if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
  161. issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
  162. }
  163. // Check: "What does this PR do?" has real content (not just placeholder text)
  164. if (hasWhatSection) {
  165. const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
  166. const whatContent = whatMatch ? whatMatch[1].trim() : '';
  167. const placeholder = 'Please provide a description of the issue';
  168. const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
  169. if (!whatContent || onlyPlaceholder) {
  170. issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
  171. }
  172. }
  173. // Check: at least one "Type of change" checkbox is checked
  174. if (hasTypeSection) {
  175. const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
  176. const typeContent = typeMatch ? typeMatch[1] : '';
  177. const hasCheckedBox = /- \[x\]/i.test(typeContent);
  178. if (!hasCheckedBox) {
  179. issues.push('No "Type of change" checkbox is checked. Please select at least one.');
  180. }
  181. }
  182. // Check: issue reference (skip for docs/refactor/feat)
  183. if (!isDocsRefactorOrFeat && hasIssueSection) {
  184. const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
  185. const issueContent = issueMatch ? issueMatch[1].trim() : '';
  186. const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
  187. if (!hasIssueRef) {
  188. issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
  189. }
  190. }
  191. // Check: "How did you verify" has content
  192. if (hasVerifySection) {
  193. const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
  194. const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
  195. if (!verifyContent) {
  196. issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
  197. }
  198. }
  199. // Check: checklist boxes are checked
  200. if (hasChecklistSection) {
  201. const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
  202. const checklistContent = checklistMatch ? checklistMatch[1] : '';
  203. const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
  204. const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
  205. if (checked < 2) {
  206. issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
  207. }
  208. }
  209. // Helper functions
  210. async function addLabel(label) {
  211. await github.rest.issues.addLabels({
  212. owner: context.repo.owner,
  213. repo: context.repo.repo,
  214. issue_number: pr.number,
  215. labels: [label]
  216. });
  217. }
  218. async function removeLabel(label) {
  219. try {
  220. await github.rest.issues.removeLabel({
  221. owner: context.repo.owner,
  222. repo: context.repo.repo,
  223. issue_number: pr.number,
  224. name: label
  225. });
  226. } catch (e) {}
  227. }
  228. const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
  229. if (issues.length > 0) {
  230. // Non-compliant
  231. if (!hasComplianceLabel) {
  232. await addLabel('needs:compliance');
  233. }
  234. const marker = '<!-- issue-compliance -->';
  235. const { data: comments } = await github.rest.issues.listComments({
  236. owner: context.repo.owner,
  237. repo: context.repo.repo,
  238. issue_number: pr.number
  239. });
  240. const existing = comments.find(c => c.body.includes(marker));
  241. const body_text = `${marker}
  242. This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
  243. **What needs to be fixed:**
  244. ${issues.map(i => `- ${i}`).join('\n')}
  245. Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
  246. If you believe this was flagged incorrectly, please let a maintainer know.`;
  247. if (existing) {
  248. await github.rest.issues.updateComment({
  249. owner: context.repo.owner,
  250. repo: context.repo.repo,
  251. comment_id: existing.id,
  252. body: body_text
  253. });
  254. } else {
  255. await github.rest.issues.createComment({
  256. owner: context.repo.owner,
  257. repo: context.repo.repo,
  258. issue_number: pr.number,
  259. body: body_text
  260. });
  261. }
  262. console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
  263. } else if (hasComplianceLabel) {
  264. // Was non-compliant, now fixed
  265. await removeLabel('needs:compliance');
  266. const { data: comments } = await github.rest.issues.listComments({
  267. owner: context.repo.owner,
  268. repo: context.repo.repo,
  269. issue_number: pr.number
  270. });
  271. const marker = '<!-- issue-compliance -->';
  272. const existing = comments.find(c => c.body.includes(marker));
  273. if (existing) {
  274. await github.rest.issues.deleteComment({
  275. owner: context.repo.owner,
  276. repo: context.repo.repo,
  277. comment_id: existing.id
  278. });
  279. }
  280. await github.rest.issues.createComment({
  281. owner: context.repo.owner,
  282. repo: context.repo.repo,
  283. issue_number: pr.number,
  284. body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
  285. });
  286. console.log(`PR #${pr.number} is now compliant, label removed`);
  287. } else {
  288. console.log(`PR #${pr.number} is compliant`);
  289. }