pr-standards.yml 15 KB

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