action-issue-close-fields.yml 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. name: Issue Close Fields
  2. on:
  3. issues:
  4. types:
  5. - closed
  6. workflow_run:
  7. workflows:
  8. - Release Issues
  9. types:
  10. - completed
  11. # checkov:skip=CKV_GHA_7:Inputs only select which closed issues a backfill run touches; nothing is built.
  12. workflow_dispatch:
  13. inputs:
  14. dry_run:
  15. description: List the changes without writing them
  16. required: true
  17. default: "true"
  18. type: choice
  19. options:
  20. - "true"
  21. - "false"
  22. closed_since:
  23. description: Only issues closed on or after this date (YYYY-MM-DD, blank = all)
  24. required: false
  25. default: ""
  26. type: string
  27. limit:
  28. description: Max issues to change (0 = no limit)
  29. required: true
  30. default: "200"
  31. type: string
  32. permissions:
  33. issues: write
  34. pull-requests: read
  35. contents: read
  36. concurrency:
  37. group: issue-close-fields-${{ github.event.issue.number || github.run_id }}
  38. jobs:
  39. issue-close-fields:
  40. if: github.repository_owner == 'GameServerManagers' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
  41. runs-on: ubuntu-latest
  42. steps:
  43. - name: Fill assignee, outcome, type and milestone on closed issues
  44. uses: actions/github-script@v9
  45. env:
  46. DEFAULT_ASSIGNEE: dgibbs64
  47. DRY_RUN: ${{ inputs.dry_run || 'false' }}
  48. CLOSED_SINCE: ${{ inputs.closed_since }}
  49. LIMIT: ${{ inputs.limit || '0' }}
  50. with:
  51. script: |
  52. const { owner, repo } = context.repo;
  53. const dryRun = process.env.DRY_RUN === 'true';
  54. const limit = Number.parseInt(process.env.LIMIT || '0', 10) || 0;
  55. const outcomeByReason = {
  56. completed: 'outcome: issue resolved',
  57. duplicate: 'outcome: duplicate',
  58. };
  59. const issueTypeByLabel = {
  60. 'type: bug': 'Bug',
  61. 'type: feature': 'Feature',
  62. 'type: game server request': 'Server Request',
  63. 'type: docs': 'Task',
  64. };
  65. let milestoneByTitle = null;
  66. let releases = null;
  67. async function loadReleaseData() {
  68. if (releases) return;
  69. const milestones = await github.paginate(github.rest.issues.listMilestones, { owner, repo, state: 'all', per_page: 100 });
  70. milestoneByTitle = new Map(milestones.map((m) => [m.title, m]));
  71. releases = (await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }))
  72. .filter((r) => !r.draft && !r.prerelease && r.published_at)
  73. .sort((a, b) => new Date(a.published_at) - new Date(b.published_at));
  74. }
  75. async function releaseMilestoneFor(sha, mergedAt) {
  76. await loadReleaseData();
  77. for (const release of releases) {
  78. if (new Date(release.published_at) < new Date(mergedAt)) continue;
  79. const { data } = await github.rest.repos.compareCommits({ owner, repo, base: release.tag_name, head: sha, per_page: 1 });
  80. if (data.status !== 'behind' && data.status !== 'identical') continue;
  81. const minor = release.tag_name.match(/^v(\d+)\.(\d+)\./);
  82. return milestoneByTitle.get(release.tag_name) || (minor && milestoneByTitle.get(`v${minor[1]}.${minor[2]}.0`)) || null;
  83. }
  84. return null;
  85. }
  86. async function milestoneFor(issueNumber) {
  87. const data = await github.graphql(
  88. `query($owner:String!,$repo:String!,$number:Int!){
  89. repository(owner:$owner,name:$repo){
  90. issue(number:$number){
  91. closedByPullRequestsReferences(first:10,includeClosedPrs:true){
  92. nodes { number merged mergedAt mergeCommit { oid } milestone { number title } }
  93. }
  94. }
  95. }
  96. }`,
  97. { owner, repo, number: issueNumber }
  98. );
  99. const prs = (data.repository?.issue?.closedByPullRequestsReferences?.nodes || [])
  100. .filter((pr) => pr.merged && pr.mergeCommit?.oid)
  101. .sort((a, b) => new Date(a.mergedAt) - new Date(b.mergedAt));
  102. if (prs.length === 0) return null;
  103. const chosen = prs.find((pr) => pr.milestone);
  104. if (chosen) return { number: chosen.milestone.number, title: chosen.milestone.title, source: `#${chosen.number}` };
  105. const released = await releaseMilestoneFor(prs[0].mergeCommit.oid, prs[0].mergedAt);
  106. return released ? { number: released.number, title: released.title, source: `#${prs[0].number} release` } : null;
  107. }
  108. async function planChanges(issue) {
  109. const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
  110. const changes = {};
  111. if ((issue.assignees || []).length === 0) changes.assignee = process.env.DEFAULT_ASSIGNEE;
  112. const outcome = outcomeByReason[issue.state_reason];
  113. if (outcome && !labels.some((l) => l.startsWith('outcome: '))) changes.label = outcome;
  114. const typeLabel = labels.find((l) => issueTypeByLabel[l]);
  115. if (!issue.type && typeLabel) changes.type = issueTypeByLabel[typeLabel];
  116. if (issue.state_reason === 'completed' && !issue.milestone) {
  117. const milestone = await milestoneFor(issue.number);
  118. if (milestone) changes.milestone = milestone;
  119. }
  120. return changes;
  121. }
  122. async function applyChanges(issueNumber, changes) {
  123. if (changes.assignee) {
  124. await github.rest.issues.addAssignees({ owner, repo, issue_number: issueNumber, assignees: [changes.assignee] });
  125. }
  126. if (changes.label) {
  127. await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [changes.label] });
  128. }
  129. const update = {};
  130. if (changes.type) update.type = changes.type;
  131. if (changes.milestone) update.milestone = changes.milestone.number;
  132. if (Object.keys(update).length > 0) {
  133. await github.rest.issues.update({ owner, repo, issue_number: issueNumber, ...update });
  134. }
  135. }
  136. function describe(changes) {
  137. const parts = [];
  138. if (changes.assignee) parts.push(`assignee ${changes.assignee}`);
  139. if (changes.label) parts.push(`label "${changes.label}"`);
  140. if (changes.type) parts.push(`type ${changes.type}`);
  141. if (changes.milestone) parts.push(`milestone ${changes.milestone.title} (from ${changes.milestone.source})`);
  142. return parts.join(', ');
  143. }
  144. let issues;
  145. if (context.eventName === 'issues') {
  146. issues = [context.payload.issue];
  147. } else {
  148. const sinceInput = context.eventName === 'workflow_run'
  149. ? new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
  150. : (process.env.CLOSED_SINCE ? new Date(`${process.env.CLOSED_SINCE}T00:00:00Z`) : null);
  151. const listParams = { owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 100 };
  152. if (sinceInput) listParams.since = sinceInput.toISOString();
  153. issues = (await github.paginate(github.rest.issues.listForRepo, listParams))
  154. .filter((issue) => !issue.pull_request)
  155. .filter((issue) => !sinceInput || new Date(issue.closed_at) >= sinceInput)
  156. .sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at));
  157. }
  158. const rows = [];
  159. const failures = [];
  160. let changed = 0;
  161. for (const issue of issues) {
  162. if (limit > 0 && changed >= limit) break;
  163. try {
  164. const changes = await planChanges(issue);
  165. if (Object.keys(changes).length === 0) continue;
  166. if (!dryRun) await applyChanges(issue.number, changes);
  167. changed += 1;
  168. rows.push([`#${issue.number}`, issue.state_reason || '', describe(changes)]);
  169. core.info(`${dryRun ? 'Would set' : 'Set'} #${issue.number}: ${describe(changes)}`);
  170. } catch (err) {
  171. failures.push(`#${issue.number}: ${err.message}`);
  172. core.warning(`#${issue.number}: ${err.message}`);
  173. if (err.status === 403 || err.status === 429) {
  174. core.warning('Rate limited, stopping. Re-run to continue: done issues are skipped.');
  175. break;
  176. }
  177. }
  178. }
  179. await core.summary
  180. .addHeading(`Issue close fields (${dryRun ? 'dry run' : 'applied'})`)
  181. .addRaw(`Checked ${issues.length} closed issue(s), ${changed} needed changes.\n\n`)
  182. .addTable([
  183. [{ data: 'Issue', header: true }, { data: 'Close reason', header: true }, { data: 'Changes', header: true }],
  184. ...rows,
  185. ])
  186. .addRaw(failures.length > 0 ? `\nFailures:\n\n${failures.map((f) => `- ${f}`).join('\n')}\n` : '')
  187. .write();