Ver código fonte

chore: automate milestones and release closing for linked issues (#5080)

* chore: sync PR milestones to linked issues

Assign the next open milestone to new PRs into develop and copy the
PR milestone to issues referenced with Fixes, Closes, Resolves or
Related to. On merge, move referenced issues to the PR milestone.

* chore: label fixed issues and close them when the release merges
Daniel Gibbs 2 dias atrás
pai
commit
e0f5741351

+ 94 - 0
.github/workflows/action-milestone-sync.yml

@@ -0,0 +1,94 @@
+name: Milestone Sync
+on:
+  pull_request_target:
+    types:
+      - opened
+      - reopened
+      - ready_for_review
+      - edited
+      - milestoned
+      - closed
+    branches:
+      - develop
+
+permissions:
+  issues: write
+  pull-requests: write
+
+concurrency:
+  group: milestone-sync-${{ github.event.pull_request.number }}
+
+jobs:
+  milestone-sync:
+    if: github.repository_owner == 'GameServerManagers'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set PR milestone and update linked issues
+        uses: actions/github-script@v9
+        with:
+          script: |
+            const { owner, repo } = context.repo;
+            const pr = context.payload.pull_request;
+            const action = context.payload.action;
+            const fixedLabel = 'status: fixed in develop';
+
+            if (action === 'closed' && !pr.merged) {
+              return;
+            }
+
+            let milestone = pr.milestone;
+            if (!milestone && ['opened', 'reopened', 'ready_for_review'].includes(action)) {
+              const open = await github.paginate(github.rest.issues.listMilestones, { owner, repo, state: 'open', per_page: 100 });
+              open.sort((a, b) => {
+                if (a.due_on && b.due_on) return a.due_on.localeCompare(b.due_on);
+                if (a.due_on) return -1;
+                if (b.due_on) return 1;
+                return a.number - b.number;
+              });
+              if (open.length === 0) {
+                core.warning('No open milestone to assign.');
+              } else {
+                milestone = open[0];
+                await github.rest.issues.update({ owner, repo, issue_number: pr.number, milestone: milestone.number });
+                core.info(`Set PR #${pr.number} milestone to ${milestone.title}.`);
+              }
+            }
+
+            const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
+            const issueRef = /\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?|related to):?\s+(?:([\w.-]+)\/([\w.-]+))?#(\d+)/gi;
+            const issues = new Map();
+            for (const m of body.matchAll(issueRef)) {
+              if (m[2] && (m[2].toLowerCase() !== owner.toLowerCase() || m[3].toLowerCase() !== repo.toLowerCase())) {
+                continue;
+              }
+              const number = Number(m[4]);
+              const closing = !/^related to$/i.test(m[1]);
+              issues.set(number, issues.get(number) || closing);
+            }
+
+            for (const [number, closing] of issues) {
+              let issue;
+              try {
+                ({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }));
+              } catch (error) {
+                core.warning(`Could not read #${number}: ${error.message}`);
+                continue;
+              }
+              if (issue.pull_request) {
+                continue;
+              }
+
+              if (milestone && issue.milestone?.number !== milestone.number) {
+                if (issue.milestone && !pr.merged) {
+                  core.info(`#${number} is in ${issue.milestone.title}; it will move to ${milestone.title} when this PR merges.`);
+                } else {
+                  await github.rest.issues.update({ owner, repo, issue_number: number, milestone: milestone.number });
+                  core.info(`Set #${number} milestone to ${milestone.title}.`);
+                }
+              }
+
+              if (pr.merged && closing && issue.state === 'open' && !issue.labels.some((l) => (l.name || l) === fixedLabel)) {
+                await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [fixedLabel] });
+                core.info(`Labelled #${number} "${fixedLabel}".`);
+              }
+            }

+ 65 - 0
.github/workflows/action-release-issues.yml

@@ -0,0 +1,65 @@
+name: Release Issues
+on:
+  pull_request_target:
+    types:
+      - opened
+      - reopened
+      - synchronize
+      - ready_for_review
+      - closed
+    branches:
+      - master
+
+permissions:
+  issues: write
+  pull-requests: write
+
+concurrency:
+  group: release-issues-${{ github.event.pull_request.number }}
+
+jobs:
+  release-issues:
+    if: github.repository_owner == 'GameServerManagers' && startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.head.repo.full_name == github.repository
+    runs-on: ubuntu-latest
+    steps:
+      - name: List fixed issues in the release PR and close them on merge
+        uses: actions/github-script@v9
+        with:
+          script: |
+            const { owner, repo } = context.repo;
+            const pr = context.payload.pull_request;
+            const fixedLabel = 'status: fixed in develop';
+            const start = '<!-- release-issues:start -->';
+            const end = '<!-- release-issues:end -->';
+
+            const labelled = (await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'all', labels: fixedLabel, per_page: 100 }))
+              .filter((issue) => !issue.pull_request)
+              .sort((a, b) => a.number - b.number);
+
+            if (context.payload.action === 'closed') {
+              if (!pr.merged) {
+                return;
+              }
+              const version = pr.head.ref.replace(/^release\//, '');
+              for (const issue of labelled) {
+                if (issue.state === 'open') {
+                  await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `Released in ${version} (#${pr.number}).` });
+                  await github.rest.issues.update({ owner, repo, issue_number: issue.number, state: 'closed', state_reason: 'completed' });
+                  core.info(`Closed #${issue.number}.`);
+                }
+                await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: fixedLabel }).catch(() => {});
+              }
+              return;
+            }
+
+            const open = labelled.filter((issue) => issue.state === 'open');
+            const lines = open.length > 0 ? open.map((issue) => `Fixes #${issue.number}`) : [`No open issues are labelled "${fixedLabel}".`];
+            const block = [start, '## Issues fixed in this release', '', ...lines, end].join('\n');
+
+            const current = pr.body || '';
+            const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
+            const updated = pattern.test(current) ? current.replace(pattern, block) : `${current.trim() ? `${current.trimEnd()}\n\n` : ''}${block}\n`;
+            if (updated !== current) {
+              await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body: updated });
+              core.info(`Listed ${open.length} issue(s) in #${pr.number}.`);
+            }