فهرست منبع

feat(ci): fill assignee, outcome, type and milestone on closed issues (#5136)

Closed issues are rarely given their sidebar fields: since 2024, 250 of
268 completed issues have no assignee, 258 no outcome label and 100 no
milestone.

Add a workflow that fills only missing values when an issue closes,
after Release Issues completes (its GITHUB_TOKEN closes fire no issues
events), and as a dry-run-by-default backfill:

- assignee dgibbs64
- outcome label from the close reason (completed, duplicate)
- issue type from the type: label
- milestone from the merged closing PR, else the first release whose
  tag contains the merge commit; left unset when none does
Daniel Gibbs 5 روز پیش
والد
کامیت
05c883eec7
1فایلهای تغییر یافته به همراه199 افزوده شده و 0 حذف شده
  1. 199 0
      .github/workflows/action-issue-close-fields.yml

+ 199 - 0
.github/workflows/action-issue-close-fields.yml

@@ -0,0 +1,199 @@
+name: Issue Close Fields
+on:
+  issues:
+    types:
+      - closed
+  workflow_run:
+    workflows:
+      - Release Issues
+    types:
+      - completed
+  # checkov:skip=CKV_GHA_7:Inputs only select which closed issues a backfill run touches; nothing is built.
+  workflow_dispatch:
+    inputs:
+      dry_run:
+        description: List the changes without writing them
+        required: true
+        default: "true"
+        type: choice
+        options:
+          - "true"
+          - "false"
+      closed_since:
+        description: Only issues closed on or after this date (YYYY-MM-DD, blank = all)
+        required: false
+        default: ""
+        type: string
+      limit:
+        description: Max issues to change (0 = no limit)
+        required: true
+        default: "200"
+        type: string
+
+permissions:
+  issues: write
+  pull-requests: read
+  contents: read
+
+concurrency:
+  group: issue-close-fields-${{ github.event.issue.number || github.run_id }}
+
+jobs:
+  issue-close-fields:
+    if: github.repository_owner == 'GameServerManagers' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Fill assignee, outcome, type and milestone on closed issues
+        uses: actions/github-script@v9
+        env:
+          DEFAULT_ASSIGNEE: dgibbs64
+          DRY_RUN: ${{ inputs.dry_run || 'false' }}
+          CLOSED_SINCE: ${{ inputs.closed_since }}
+          LIMIT: ${{ inputs.limit || '0' }}
+        with:
+          script: |
+            const { owner, repo } = context.repo;
+            const dryRun = process.env.DRY_RUN === 'true';
+            const limit = Number.parseInt(process.env.LIMIT || '0', 10) || 0;
+            const outcomeByReason = {
+              completed: 'outcome: issue resolved',
+              duplicate: 'outcome: duplicate',
+            };
+            const issueTypeByLabel = {
+              'type: bug': 'Bug',
+              'type: feature': 'Feature',
+              'type: game server request': 'Server Request',
+              'type: docs': 'Task',
+            };
+
+            let milestoneByTitle = null;
+            let releases = null;
+            async function loadReleaseData() {
+              if (releases) return;
+              const milestones = await github.paginate(github.rest.issues.listMilestones, { owner, repo, state: 'all', per_page: 100 });
+              milestoneByTitle = new Map(milestones.map((m) => [m.title, m]));
+              releases = (await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }))
+                .filter((r) => !r.draft && !r.prerelease && r.published_at)
+                .sort((a, b) => new Date(a.published_at) - new Date(b.published_at));
+            }
+
+            async function releaseMilestoneFor(sha, mergedAt) {
+              await loadReleaseData();
+              for (const release of releases) {
+                if (new Date(release.published_at) < new Date(mergedAt)) continue;
+                const { data } = await github.rest.repos.compareCommits({ owner, repo, base: release.tag_name, head: sha, per_page: 1 });
+                if (data.status !== 'behind' && data.status !== 'identical') continue;
+                const minor = release.tag_name.match(/^v(\d+)\.(\d+)\./);
+                return milestoneByTitle.get(release.tag_name) || (minor && milestoneByTitle.get(`v${minor[1]}.${minor[2]}.0`)) || null;
+              }
+              return null;
+            }
+
+            async function milestoneFor(issueNumber) {
+              const data = await github.graphql(
+                `query($owner:String!,$repo:String!,$number:Int!){
+                  repository(owner:$owner,name:$repo){
+                    issue(number:$number){
+                      closedByPullRequestsReferences(first:10,includeClosedPrs:true){
+                        nodes { number merged mergedAt mergeCommit { oid } milestone { number title } }
+                      }
+                    }
+                  }
+                }`,
+                { owner, repo, number: issueNumber }
+              );
+              const prs = (data.repository?.issue?.closedByPullRequestsReferences?.nodes || [])
+                .filter((pr) => pr.merged && pr.mergeCommit?.oid)
+                .sort((a, b) => new Date(a.mergedAt) - new Date(b.mergedAt));
+              if (prs.length === 0) return null;
+              const chosen = prs.find((pr) => pr.milestone);
+              if (chosen) return { number: chosen.milestone.number, title: chosen.milestone.title, source: `#${chosen.number}` };
+              const released = await releaseMilestoneFor(prs[0].mergeCommit.oid, prs[0].mergedAt);
+              return released ? { number: released.number, title: released.title, source: `#${prs[0].number} release` } : null;
+            }
+
+            async function planChanges(issue) {
+              const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
+              const changes = {};
+              if ((issue.assignees || []).length === 0) changes.assignee = process.env.DEFAULT_ASSIGNEE;
+              const outcome = outcomeByReason[issue.state_reason];
+              if (outcome && !labels.some((l) => l.startsWith('outcome: '))) changes.label = outcome;
+              const typeLabel = labels.find((l) => issueTypeByLabel[l]);
+              if (!issue.type && typeLabel) changes.type = issueTypeByLabel[typeLabel];
+              if (issue.state_reason === 'completed' && !issue.milestone) {
+                const milestone = await milestoneFor(issue.number);
+                if (milestone) changes.milestone = milestone;
+              }
+              return changes;
+            }
+
+            async function applyChanges(issueNumber, changes) {
+              if (changes.assignee) {
+                await github.rest.issues.addAssignees({ owner, repo, issue_number: issueNumber, assignees: [changes.assignee] });
+              }
+              if (changes.label) {
+                await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [changes.label] });
+              }
+              const update = {};
+              if (changes.type) update.type = changes.type;
+              if (changes.milestone) update.milestone = changes.milestone.number;
+              if (Object.keys(update).length > 0) {
+                await github.rest.issues.update({ owner, repo, issue_number: issueNumber, ...update });
+              }
+            }
+
+            function describe(changes) {
+              const parts = [];
+              if (changes.assignee) parts.push(`assignee ${changes.assignee}`);
+              if (changes.label) parts.push(`label "${changes.label}"`);
+              if (changes.type) parts.push(`type ${changes.type}`);
+              if (changes.milestone) parts.push(`milestone ${changes.milestone.title} (from ${changes.milestone.source})`);
+              return parts.join(', ');
+            }
+
+            let issues;
+            if (context.eventName === 'issues') {
+              issues = [context.payload.issue];
+            } else {
+              const sinceInput = context.eventName === 'workflow_run'
+                ? new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
+                : (process.env.CLOSED_SINCE ? new Date(`${process.env.CLOSED_SINCE}T00:00:00Z`) : null);
+              const listParams = { owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 100 };
+              if (sinceInput) listParams.since = sinceInput.toISOString();
+              issues = (await github.paginate(github.rest.issues.listForRepo, listParams))
+                .filter((issue) => !issue.pull_request)
+                .filter((issue) => !sinceInput || new Date(issue.closed_at) >= sinceInput)
+                .sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at));
+            }
+
+            const rows = [];
+            const failures = [];
+            let changed = 0;
+            for (const issue of issues) {
+              if (limit > 0 && changed >= limit) break;
+              try {
+                const changes = await planChanges(issue);
+                if (Object.keys(changes).length === 0) continue;
+                if (!dryRun) await applyChanges(issue.number, changes);
+                changed += 1;
+                rows.push([`#${issue.number}`, issue.state_reason || '', describe(changes)]);
+                core.info(`${dryRun ? 'Would set' : 'Set'} #${issue.number}: ${describe(changes)}`);
+              } catch (err) {
+                failures.push(`#${issue.number}: ${err.message}`);
+                core.warning(`#${issue.number}: ${err.message}`);
+                if (err.status === 403 || err.status === 429) {
+                  core.warning('Rate limited, stopping. Re-run to continue: done issues are skipped.');
+                  break;
+                }
+              }
+            }
+
+            await core.summary
+              .addHeading(`Issue close fields (${dryRun ? 'dry run' : 'applied'})`)
+              .addRaw(`Checked ${issues.length} closed issue(s), ${changed} needed changes.\n\n`)
+              .addTable([
+                [{ data: 'Issue', header: true }, { data: 'Close reason', header: true }, { data: 'Changes', header: true }],
+                ...rows,
+              ])
+              .addRaw(failures.length > 0 ? `\nFailures:\n\n${failures.map((f) => `- ${f}`).join('\n')}\n` : '')
+              .write();