next.js/.github/actions/pr-auto-label/src/index.ts
index.ts135 lines3.6 KB
import { info, setFailed, warning } from '@actions/core'
import { context, getOctokit } from '@actions/github'
import { minimatch } from 'minimatch'
import config from './config.json'

type AuthorRule = { type: 'user'; pattern: string }
type LabelRule = string | AuthorRule
type LabelerConfig = { labels: Record<string, LabelRule[]> }

function isAuthorRule(rule: LabelRule): rule is AuthorRule {
  return (
    typeof rule === 'object' &&
    rule !== null &&
    'type' in rule &&
    rule.type === 'user'
  )
}

/**
 * Compute the set of labels to apply for a pull request.
 */
export function computeLabels(
  config: LabelerConfig,
  author: string,
  changedFiles: string[]
): string[] {
  const matched = new Set<string>()

  for (const [label, rules] of Object.entries(config.labels)) {
    for (const rule of rules) {
      if (isAuthorRule(rule)) {
        if (rule.pattern.toLowerCase() === author.toLowerCase()) {
          matched.add(label)
          break
        }
      } else if (typeof rule === 'string') {
        const matches = changedFiles.some((file) =>
          minimatch(file, rule, { dot: true, matchBase: false })
        )
        if (matches) {
          matched.add(label)
          break
        }
      } else {
        warning(
          `Unknown rule type for label "${label}": ${JSON.stringify(rule)}`
        )
      }
    }
  }

  return Array.from(matched)
}

async function main() {
  // Read the token strictly from the environment so a caller can't pass an
  // unrelated higher-privileged token via an action input.
  const token = process.env.GITHUB_TOKEN
  if (!token) {
    throw new TypeError(
      'GITHUB_TOKEN is not set. Ensure the calling workflow grants `pull-requests: write`.'
    )
  }

  const pr = context.payload.pull_request
  if (!pr) {
    info('No pull_request payload present; skipping.')
    return
  }

  const { owner, repo } = context.repo
  const prNumber: number = pr.number
  const author: string | undefined = pr.user?.login
  if (!author) {
    warning('Pull request has no author login; skipping.')
    return
  }

  const octokit = getOctokit(token)

  const changedFiles: string[] = await octokit.paginate(
    octokit.rest.pulls.listFiles,
    { owner, repo, pull_number: prNumber, per_page: 100 },
    (response) => response.data.map((f) => f.filename)
  )

  info(`PR #${prNumber} author: ${author}`)
  info(`PR #${prNumber} changed files: ${changedFiles.length}`)

  const allowedLabels = new Set(Object.keys((config as LabelerConfig).labels))
  const labelsToAdd = computeLabels(
    config as LabelerConfig,
    author,
    changedFiles
  ).filter((label) => {
    // Defense in depth: `addLabels` will create missing labels on the fly,
    // so ensure the set we send is always a subset of the config's keys.
    // `computeLabels` already enforces this, but an explicit filter here
    // keeps the invariant local to the API call.
    if (!allowedLabels.has(label)) {
      warning(`Skipping unknown label "${label}" not declared in config.`)
      return false
    }
    return true
  })

  if (labelsToAdd.length === 0) {
    info('No labels matched.')
    return
  }

  info(`Applying labels: ${labelsToAdd.join(', ')}`)

  try {
    await octokit.rest.issues.addLabels({
      owner,
      repo,
      issue_number: prNumber,
      labels: labelsToAdd,
    })
  } catch (error) {
    setFailed(
      `Failed to apply labels to PR #${prNumber}: ${
        error instanceof Error ? (error.stack ?? error.message) : String(error)
      }`
    )
  }
}

main().catch((error) => {
  setFailed(
    error instanceof Error ? (error.stack ?? error.message) : String(error)
  )
})
Quest for Codev2.0.0
/
SIGN IN