Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
milestones.ts 5.76 KiB
import _ from "underscore";

import { getMilestones } from "./github";
import { getLinkedIssues, getPRsFromCommitMessage, getBackportSourcePRNumber } 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) && pull.data.body) {
    const sourcePRNumber = getBackportSourcePRNumber(pull.data.body);
    if (sourcePRNumber && sourcePRNumber !== pullRequestNumber) {
      console.log('found backport PR', pull.data.number, 'source PR', sourcePRNumber);
      return getOriginalPR({
        github,
        repo,
        owner,
        pullRequestNumber: sourcePRNumber,
      });
    }
  }

  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 = _.uniq(
    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,
    })));
  }

  const uniqueIssuesToTag = _.uniq(issuesToTag);

  console.log(`Tagging ${uniqueIssuesToTag.length} issues with milestone ${nextMilestone.title}`)

  for (const issueNumber of uniqueIssuesToTag) { // for loop to avoid rate limiting
    await setMilestone({ github, owner, repo, issueNumber, milestone: nextMilestone });
  }
}