From 9e4cbda4772a9f92623a3b71c8a810d286fd8cc1 Mon Sep 17 00:00:00 2001
From: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Date: Wed, 19 Jun 2024 09:54:36 -0600
Subject: [PATCH] ci: Automatically add milestones to prs and issues (#44357)

* automatically add milestones to prs and issues

* test code

* test code

* test commit (#3138)

* test commit (#43320)

* test commit (#44357)

* remove test code

* add newlines

* prefer older version milestones
---
 .github/workflows/check-milestone.yml  |  77 +++-------
 release/src/github.ts                  |   2 +-
 release/src/index.ts                   |   1 +
 release/src/linked-issues.ts           |  10 ++
 release/src/linked-issues.unit.spec.ts |  26 +++-
 release/src/milestones.ts              | 185 +++++++++++++++++++++++++
 release/src/milestones.unit.spec.ts    |  74 ++++++++++
 release/src/types.ts                   |  20 ++-
 release/tsconfig.json                  |   3 +-
 9 files changed, 331 insertions(+), 67 deletions(-)
 create mode 100644 release/src/milestones.ts
 create mode 100644 release/src/milestones.unit.spec.ts

diff --git a/.github/workflows/check-milestone.yml b/.github/workflows/check-milestone.yml
index 032819ec835..bd9e8ab7cbd 100644
--- a/.github/workflows/check-milestone.yml
+++ b/.github/workflows/check-milestone.yml
@@ -1,86 +1,41 @@
-name: Milestone Reminder
+name: Automatically set milestone
+run-name: Set milestone for ${{ github.event.head_commit.message }}
 
 on:
-  pull_request:
-    types: [closed]
+  push:
     branches:
-      - "master"
+      - release-x.**
 
 jobs:
-  milestone-reminder:
-    if: github.event.pull_request.merged == true
-    name: Remind to set milestone
+  set-milestone:
+    name: Automatically set milestone
     runs-on: ubuntu-22.04
     timeout-minutes: 5
     permissions:
       pull-requests: write
-      issues: read
+      issues: write
+      contents: read
     steps:
       - uses: actions/checkout@v4
         with:
           sparse-checkout: release
+          ref: master # we want the logic from master, even though we're triggering on a release branch
       - name: Prepare build scripts
         run: cd ${{ github.workspace }}/release && yarn && yarn build
       - uses: actions/github-script@v7
         with:
           script: | # js
-            const { getLinkedIssues } = require('${{ github.workspace }}/release/dist/index.cjs');
+            const { setMilestoneForCommits } = require('${{ github.workspace }}/release/dist/index.cjs');
             const owner = context.repo.owner;
             const repo = context.repo.repo;
 
-            const pullNumber = '${{ github.event.pull_request.number }}';
+            const branchName = '${{ github.ref }}';
+            const commitMessage = '${{ github.event.head_commit.message }}';
 
-            function getMilestone(pullOrIssue) {
-              // pull has pull.data.milestone, issue has issue.milestone
-              return pullOrIssue?.data?.milestone || pullOrIssue?.milestone;
-            }
-
-            const pull = await github.rest.pulls.get({
+            await setMilestoneForCommits({
+              github,
               owner,
               repo,
-              pull_number: pullNumber,
+              branchName,
+              commitMessages: [commitMessage],
             });
-
-            if (getMilestone(pull)) {
-              console.log("Pull request has milestone", pull.data.milestone?.title);
-              process.exit(0);
-            }
-
-            // the github API doesn't expose linked issues, so we have to parse the body ourselves
-            const prDescription = pull.data.body;
-            const linkedIssues = getLinkedIssues(prDescription);
-
-            if (Array.isArray(linkedIssues) && linkedIssues.length > 0) {
-              console.log('linked issue(s) found');
-
-              linkedIssues.forEach(async (issueId, index) => {
-                const issue = await github.rest.issues.get({
-                  owner,
-                  repo,
-                  issue_number: issueId,
-                });
-
-                const milestone = getMilestone(issue);
-
-                if (milestone) {
-                  console.log(`Linked issue #${issueId} has milestone`, milestone.title);
-                  index === linkedIssues.length -1 && process.exit(0);
-                } else {
-                    console.log("No milestone found");
-
-                    const author = pull.data.user.login;
-                    const guideLink = "https://www.notion.so/metabase/Metabase-Branching-Strategy-6eb577d5f61142aa960a626d6bbdfeb3?pvs=4#3dea255ffa3b4f74942a227844e889fa";
-                    const message = `@${author} Did you forget to add a milestone to the issue #${issueId} linked in this PR? _[When and where should I add a milestone?](${guideLink})_`;
-
-                    await github.rest.issues.createComment({
-                      owner,
-                      repo,
-                      issue_number: pullNumber,
-                      body: message,
-                    });
-
-                    // Exit as soon as we found an issue without a milestone and alerted the author.
-                    process.exit(0);
-                }
-              });
-            }
diff --git a/release/src/github.ts b/release/src/github.ts
index 6a131d1c2d8..7b98e0eaf26 100644
--- a/release/src/github.ts
+++ b/release/src/github.ts
@@ -7,7 +7,7 @@ import {
   isValidVersionString,
 } from "./version-helpers";
 
-const getMilestones = async ({
+export const getMilestones = async ({
   github,
   owner,
   repo,
diff --git a/release/src/index.ts b/release/src/index.ts
index 42266358774..7e38d880a90 100644
--- a/release/src/index.ts
+++ b/release/src/index.ts
@@ -1,6 +1,7 @@
 export * from "./backports";
 export * from "./github";
 export * from "./linked-issues";
+export * from "./milestones";
 export * from "./release-notes";
 export * from "./release-status";
 export * from "./slack";
diff --git a/release/src/linked-issues.ts b/release/src/linked-issues.ts
index 7e5fe232796..c76892720eb 100644
--- a/release/src/linked-issues.ts
+++ b/release/src/linked-issues.ts
@@ -12,3 +12,13 @@ export function getLinkedIssues(body: string) {
 
   return null;
 }
+
+export function getPRsFromCommitMessage(message: string) {
+  const result = [ ...message.matchAll(/\(#(\d+)\)/g) ];
+  if (!result.length) {
+    console.log('no pr found in commit message', message);
+    return null;
+  }
+
+  return result.map(r => Number(r[1]));
+}
diff --git a/release/src/linked-issues.unit.spec.ts b/release/src/linked-issues.unit.spec.ts
index d589dd7524f..b832b4522d1 100644
--- a/release/src/linked-issues.unit.spec.ts
+++ b/release/src/linked-issues.unit.spec.ts
@@ -1,4 +1,4 @@
-import { getLinkedIssues } from "./linked-issues";
+import { getLinkedIssues, getPRsFromCommitMessage } from "./linked-issues";
 
 const closingKeywords = [
   "Close",
@@ -107,3 +107,27 @@ describe("getLinkedIssues", () => {
     });
   });
 });
+
+describe("getPRsFromCommitMessage", () => {
+  it("should return `null` when no PR is found", () => {
+    expect(getPRsFromCommitMessage("")).toBeNull();
+    expect(getPRsFromCommitMessage("Lorem ipsum dolor sit amet.")).toBeNull();
+    expect(getPRsFromCommitMessage("Fix #123.")).toBeNull();
+    expect(getPRsFromCommitMessage("Fix#123.")).toBeNull();
+    expect(getPRsFromCommitMessage("Fix # 123.")).toBeNull();
+    expect(getPRsFromCommitMessage("123 456")).toBeNull();
+    expect(getPRsFromCommitMessage("123 #456)")).toBeNull();
+    expect(getPRsFromCommitMessage("123 (#456")).toBeNull();
+    expect(getPRsFromCommitMessage("123 (#456.99)")).toBeNull();
+  });
+
+  it("should return the PR id for a single pr backport", () => {
+    expect(getPRsFromCommitMessage("Backport (#123)")).toEqual([123]);
+    expect(getPRsFromCommitMessage("Backport (#123456) !")).toEqual([123456]);
+  });
+
+  it("should return the PR id for a message with multiple backport PRs", () => {
+    expect(getPRsFromCommitMessage("Backport (#123) (#456)")).toEqual([123, 456]);
+    expect(getPRsFromCommitMessage("Backport (#1234) and (#4567)")).toEqual([1234, 4567]);
+  });
+});
diff --git a/release/src/milestones.ts b/release/src/milestones.ts
new file mode 100644
index 00000000000..ee0804ab67e
--- /dev/null
+++ b/release/src/milestones.ts
@@ -0,0 +1,185 @@
+import { getMilestones } from "./github";
+import { getLinkedIssues, getPRsFromCommitMessage } from "./linked-issues";
+import type { Issue, GithubProps, Milestone } from "./types";
+import {
+  getMajorVersion,
+  getVersionFromReleaseBranch,
+} from "./version-helpers";
+
+function isBackport(pullRequest: Issue) {
+  return pullRequest.title.includes('backport') ||
+    (
+      Array.isArray(pullRequest.labels) &&
+      pullRequest.labels.some((label) => label.name === 'was-backported')
+    );
+}
+
+// for auto-setting milestones, we don't ever want to auto-set a patch milestone
+// which we release VERY rarely
+function ignorePatches(version: string) {
+  return version.split('.').length < 4;
+}
+
+function versionSort(a: string, b: string) {
+  const [aMajor, aMinor] = a.split('.').map(Number);
+  const [bMajor, bMinor] = b.split('.').map(Number);
+
+  if (aMajor !== bMajor) {
+    return aMajor - bMajor;
+  }
+
+  if (aMinor !== bMinor) {
+    return aMinor - bMinor;
+  }
+
+  return 0;
+}
+
+const isNotNull = <T>(value: T | null): value is T => value !== null;
+
+
+async function getOriginalPR({
+  github,
+  repo,
+  owner,
+  pullRequestNumber,
+}: GithubProps & { pullRequestNumber: number }) {
+  // every PR in the release branch should have a pr number
+  // it could be a backport PR or an original PR
+  const pull = await github.rest.pulls.get({
+    owner,
+    repo,
+    pull_number: pullRequestNumber,
+  });
+
+  if (pull?.data && isBackport(pull.data)) {
+    return getOriginalPR({
+      github,
+      repo,
+      owner,
+      pullRequestNumber: pull.data.number
+    });
+  }
+
+  const linkedIssues = await getLinkedIssues(pull.data.body ?? '');
+
+  if (linkedIssues) {
+    console.log('found linked issue for PR', pull.data.number, linkedIssues);
+    return linkedIssues.map(Number);
+  }
+  console.log("no linked issues found in PR body", pull.data.number);
+  return [pull.data.number];
+}
+
+async function setMilestone({ github, owner, repo, issueNumber, milestone }: GithubProps & { issueNumber: number, milestone: Milestone }) {
+  // we can use this for both issues and PRs since they're the same for many purposes in github
+  const issue = await github.rest.issues.get({
+    owner,
+    repo,
+    issue_number: issueNumber,
+  });
+
+  if (!issue.data.milestone) {
+    return github.rest.issues.update({
+      owner,
+      repo,
+      issue_number: issueNumber,
+      milestone: milestone.number,
+    });
+  }
+
+  const existingMilestone = issue.data.milestone;
+
+  if (existingMilestone.number === milestone.number) {
+    console.log(`Issue ${issueNumber} is already tagged with this ${milestone.title} milestone`);
+    return;
+  }
+
+  const existingMilestoneIsNewer = versionSort(existingMilestone.title, milestone.title) > 0;
+
+  // if existing milestone is newer, change it
+  if (existingMilestoneIsNewer) {
+    console.log(`Changing milestone from ${existingMilestone.title} to ${milestone.title}`);
+
+    await github.rest.issues.update({
+      owner,
+      repo,
+      issue_number: issueNumber,
+      milestone: milestone.number,
+    });
+  }
+
+  const commentBody = existingMilestoneIsNewer
+    ? `🚀 This should also be released by [v${existingMilestone.title}](${existingMilestone.html_url})`
+    : `🚀 This should also be released by [v${milestone.title}](${milestone.html_url})`;
+
+  console.log(`Adding comment to issue ${issueNumber} that already has milestone ${existingMilestone.title}`);
+
+  return github.rest.issues.createComment({
+    owner,
+    repo,
+    issue_number: issueNumber,
+    body: commentBody,
+  });
+}
+
+// get the next open milestone (e.g. 0.57.8) for the given major version (e.g 57)
+export function getNextMilestone(
+  { openMilestones, majorVersion }:
+  { openMilestones: Milestone[], majorVersion: number | string }
+): Milestone | undefined {
+  const milestonesForThisMajorVersion = openMilestones
+    .filter(milestone => milestone.title.startsWith(`0.${majorVersion}`))
+    .filter(milestone => ignorePatches(milestone.title))
+    .sort((a, b) => versionSort(a.title, b.title));
+
+  const nextMilestone = milestonesForThisMajorVersion[0];
+
+  return nextMilestone;
+}
+
+export async function setMilestoneForCommits({
+  github,
+  owner,
+  repo,
+  branchName,
+  commitMessages,
+}: GithubProps & { commitMessages: string[], branchName: string}) {
+  // figure out milestone
+  const branchVersion = getVersionFromReleaseBranch(branchName);
+  const majorVersion = getMajorVersion(branchVersion);
+  const openMilestones = await getMilestones({ github, owner, repo });
+  const nextMilestone = getNextMilestone({ openMilestones, majorVersion });
+
+  if (!nextMilestone) {
+    throw new Error(`No open milestone found for major version ${majorVersion}`);
+  }
+
+  console.log('Next milestone:', nextMilestone.title);
+
+  // figure out issue or PR
+  const PRsToCheck = commitMessages.flatMap(getPRsFromCommitMessage).filter(isNotNull);
+
+  if (!PRsToCheck.length) {
+    throw new Error('No PRs found in commit messages');
+  }
+
+  console.log(`Checking ${PRsToCheck.length} PRs for issues to tag`);
+
+  const issuesToTag = [];
+
+  for (const prNumber of PRsToCheck) { // for loop to avoid rate limiting
+    issuesToTag.push(...(await getOriginalPR({
+      github,
+      owner,
+      repo,
+      pullRequestNumber: prNumber,
+    })));
+  }
+
+  console.log(`Tagging ${issuesToTag.length} issues with milestone ${nextMilestone.title}`)
+
+  for (const issueNumber of issuesToTag) { // for loop to avoid rate limiting
+    await setMilestone({ github, owner, repo, issueNumber, milestone: nextMilestone });
+  }
+}
diff --git a/release/src/milestones.unit.spec.ts b/release/src/milestones.unit.spec.ts
new file mode 100644
index 00000000000..059a07844b7
--- /dev/null
+++ b/release/src/milestones.unit.spec.ts
@@ -0,0 +1,74 @@
+import { getNextMilestone } from "./milestones";
+import type { Milestone } from "./types";
+
+describe('milestones', () => {
+  const testMilestones = [
+    { number: 577, title: '0.57.7' },
+    { number: 578, title: '0.57.8' },
+    { number: 580, title: '0.58' },
+    { number: 581, title: '0.58.1' },
+    { number: 599, title: '0.59.9' },
+    { number: 5910, title: '0.59.10' },
+    { number: 600, title: '0.60' },
+    { number: 6010, title: '0.60.10' },
+    { number: 6111, title: '0.61.1.1' },
+    { number: 612, title: '0.61.2' },
+    { number: 613, title: '0.61.3'},
+    { number: 621, title: '0.62.1'},
+    { number: 6220, title: '0.62.20'},
+    { number: 62300, title: '0.62.300'},
+    { number: 630, title: '0.63'},
+  ] as Milestone[];
+
+  describe('getNextMilestone', () => {
+    it('can detect the next milestone for minor versions', () => {
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 57,
+      })?.number).toBe(577);
+
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 59,
+      })?.number).toBe(599);
+
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 62,
+      })?.number).toBe(621);
+    });
+
+    it('can detect the next milestone for major versions', () => {
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 63,
+      })?.number).toBe(630);
+    });
+
+    it('can detect the next milestone for mixed major and minor versions', () => {
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 58,
+      })?.number).toBe(580);
+
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 60,
+      })?.number).toBe(600);
+    });
+
+    it('should return undefined when milestone is missing', () => {
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 56,
+      })?.number).toBe(undefined);
+    });
+
+    it('should ignore patch versions', () => {
+      expect(getNextMilestone({
+        openMilestones: testMilestones,
+        majorVersion: 61,
+      })?.number).toBe(612);
+    });
+  });
+});
diff --git a/release/src/types.ts b/release/src/types.ts
index e655cdbaf2a..a3172403ac7 100644
--- a/release/src/types.ts
+++ b/release/src/types.ts
@@ -15,14 +15,16 @@ export interface CacheType {
   slackChannelId?: string;
   preReleaseActionId?: number;
 }
-
-export interface ReleaseProps {
+export interface GithubProps {
   owner: string;
   repo: string;
-  version: string;
   github: Octokit;
 }
 
+export interface ReleaseProps extends GithubProps {
+  version: string;
+}
+
 export interface VersionInfo {
   version: string;
   released: string;
@@ -43,3 +45,15 @@ export type Issue = {
   assignee: null |  { login: string };
   created_at: string;
 };
+
+export type Milestone =  {
+  url: string;
+  html_url: string;
+  labels_url: string;
+  id: number;
+  node_id: string;
+  number: number;
+  state: "open" | "closed";
+  title: string;
+  description: string | null;
+};
diff --git a/release/tsconfig.json b/release/tsconfig.json
index fe3c32e1c41..03fe48018aa 100644
--- a/release/tsconfig.json
+++ b/release/tsconfig.json
@@ -9,7 +9,8 @@
     "experimentalDecorators": true,
     "forceConsistentCasingInFileNames": true,
     "lib": ["dom", "dom.iterable", "esnext"],
-    "outDir": "./dist"
+    "outDir": "./dist",
+    "target": "esnext"
   },
   "include": ["src/**/*.ts", "src/index.mts"]
 }
-- 
GitLab