action-pr-body-check.yml 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. name: PR Body Check
  2. on:
  3. pull_request:
  4. types:
  5. - opened
  6. - edited
  7. - reopened
  8. - synchronize
  9. branches:
  10. - develop
  11. permissions:
  12. contents: read
  13. issues: read
  14. pull-requests: read
  15. jobs:
  16. pr-body-check:
  17. if: github.repository_owner == 'GameServerManagers' && github.event.pull_request.user.type != 'Bot' && !startsWith(github.event.pull_request.head.ref, 'repo-sync/')
  18. runs-on: ubuntu-latest
  19. steps:
  20. - name: Check pull request body follows the template
  21. uses: actions/github-script@v9
  22. with:
  23. script: |
  24. const body = (context.payload.pull_request.body || '').replace(/<!--[\s\S]*?-->/g, '');
  25. const requiredHeadings = ['# Description', '## Type of change', '## Testing', '## Checklist'];
  26. const missing = requiredHeadings.filter((h) => !body.split(/\r?\n/).some((line) => line.trim() === h));
  27. const errors = [];
  28. if (missing.length > 0) {
  29. errors.push(`Missing template sections: ${missing.join(', ')}. Use .github/pull_request_template.md as the pull request body.`);
  30. }
  31. if (/^\s*fixes #\[issue\]\s*$/im.test(body)) {
  32. errors.push('Replace the "Fixes #[issue]" placeholder with the issue number, for example "Fixes #123".');
  33. }
  34. const closingKeyword = /\b(close[sd]?|fix(e[sd])?|resolve[sd]?):?\s+([\w.-]+\/[\w.-]+)?#\d+/i;
  35. const relatedIssue = /\brelated to:?\s+([\w.-]+\/[\w.-]+)?#\d+/i;
  36. if (!closingKeyword.test(body) && !relatedIssue.test(body)) {
  37. core.warning('No closing keyword found. Add "Fixes #123" to link the issue, or "Related to #123" if this pull request does not fully resolve it.');
  38. }
  39. const repoName = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
  40. const closingRefs = [...body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+([\w.-]+\/[\w.-]+)?#(\d+)/gi)]
  41. .filter((m) => !m[1] || m[1].toLowerCase() === repoName)
  42. .map((m) => Number(m[2]));
  43. const wanted = [...new Set(closingRefs)];
  44. if (wanted.length > 0) {
  45. const vars = { owner: context.repo.owner, repo: context.repo.repo };
  46. const { repository } = await github.graphql(
  47. `query($owner: String!, $repo: String!, $number: Int!) {
  48. repository(owner: $owner, name: $repo) {
  49. pullRequest(number: $number) { closingIssuesReferences(first: 50) { nodes { number } } }
  50. }
  51. }`,
  52. { ...vars, number: context.payload.pull_request.number }
  53. );
  54. const linked = new Set(repository.pullRequest.closingIssuesReferences.nodes.map((n) => n.number));
  55. const unlinked = [];
  56. for (const number of wanted.filter((n) => !linked.has(n))) {
  57. try {
  58. const { repository: ref } = await github.graphql(
  59. `query($owner: String!, $repo: String!, $number: Int!) {
  60. repository(owner: $owner, name: $repo) { issueOrPullRequest(number: $number) { __typename } }
  61. }`,
  62. { ...vars, number }
  63. );
  64. if (ref.issueOrPullRequest?.__typename === 'Issue') {
  65. unlinked.push(`#${number}`);
  66. }
  67. } catch (error) {
  68. core.warning(`Could not look up #${number}: ${error.message}`);
  69. }
  70. }
  71. if (unlinked.length > 0) {
  72. errors.push(`Not linked in Development: ${unlinked.join(', ')}. Closing keywords in pull requests to develop do not link issues. Select each issue in the Development section of this pull request's sidebar, then re-run this check.`);
  73. }
  74. }
  75. if (errors.length > 0) {
  76. core.setFailed(errors.join('\n'));
  77. }