import * as core from '@actions/core'; import * as github from '@actions/github'; import * as yaml from 'js-yaml'; import {Minimatch, IMinimatch} from 'minimatch'; import {checkBranch, toMatchConfigWithBranches} from './branch'; interface MatchConfig { all?: string[]; any?: string[]; headBranch?: string[]; baseBranch?: string[]; } type StringOrMatchConfig = string | MatchConfig; type ClientType = ReturnType; export async function run() { try { const token = core.getInput('repo-token', {required: true}); const configPath = core.getInput('configuration-path', {required: true}); const syncLabels = !!core.getInput('sync-labels', {required: false}); const prNumber = getPrNumber(); if (!prNumber) { console.log('Could not get pull request number from context, exiting'); return; } const client: ClientType = github.getOctokit(token); const {data: pullRequest} = await client.rest.pulls.get({ owner: github.context.repo.owner, repo: github.context.repo.repo, pull_number: prNumber }); core.debug(`fetching changed files for pr #${prNumber}`); const changedFiles: string[] = await getChangedFiles(client, prNumber); const labelGlobs: Map = await getLabelGlobs( client, configPath ); const labels: string[] = []; const labelsToRemove: string[] = []; for (const [label, globs] of labelGlobs.entries()) { core.debug(`processing ${label}`); if (checkGlobs(changedFiles, globs)) { labels.push(label); } else if (pullRequest.labels.find(l => l.name === label)) { labelsToRemove.push(label); } } if (labels.length > 0) { await addLabels(client, prNumber, labels); } if (syncLabels && labelsToRemove.length) { await removeLabels(client, prNumber, labelsToRemove); } } catch (error: any) { core.error(error); core.setFailed(error.message); } } function getPrNumber(): number | undefined { const pullRequest = github.context.payload.pull_request; if (!pullRequest) { return undefined; } return pullRequest.number; } async function getChangedFiles( client: ClientType, prNumber: number ): Promise { const listFilesOptions = client.rest.pulls.listFiles.endpoint.merge({ owner: github.context.repo.owner, repo: github.context.repo.repo, pull_number: prNumber }); const listFilesResponse = await client.paginate(listFilesOptions); const changedFiles = listFilesResponse.map((f: any) => f.filename); core.debug('found changed files:'); for (const file of changedFiles) { core.debug(' ' + file); } return changedFiles; } async function getLabelGlobs( client: ClientType, configurationPath: string ): Promise> { const configurationContent: string = await fetchContent( client, configurationPath ); // loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`: const configObject: any = yaml.load(configurationContent); // transform `any` => `Map` or throw if yaml is malformed: return getLabelGlobMapFromObject(configObject); } async function fetchContent( client: ClientType, repoPath: string ): Promise { const response: any = await client.rest.repos.getContent({ owner: github.context.repo.owner, repo: github.context.repo.repo, path: repoPath, ref: github.context.sha }); return Buffer.from(response.data.content, response.data.encoding).toString(); } function getLabelGlobMapFromObject( configObject: any ): Map { const labelGlobs: Map = new Map(); for (const label in configObject) { if (typeof configObject[label] === 'string') { labelGlobs.set(label, [configObject[label]]); } else if (configObject[label] instanceof Array) { labelGlobs.set(label, configObject[label]); } else { throw Error( `found unexpected type for label ${label} (should be string or array of globs)` ); } } return labelGlobs; } function toMatchConfig(config: StringOrMatchConfig): MatchConfig { if (typeof config === 'string') { return { any: [config] }; } return toMatchConfigWithBranches(config); } function printPattern(matcher: IMinimatch): string { return (matcher.negate ? '!' : '') + matcher.pattern; } export function checkGlobs( changedFiles: string[], globs: StringOrMatchConfig[] ): boolean { for (const glob of globs) { core.debug(` checking pattern ${JSON.stringify(glob)}`); const matchConfig = toMatchConfig(glob); if (checkMatch(changedFiles, matchConfig)) { return true; } } return false; } function isMatch(changedFile: string, matchers: IMinimatch[]): boolean { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { core.debug(` - ${printPattern(matcher)}`); if (!matcher.match(changedFile)) { core.debug(` ${printPattern(matcher)} did not match`); return false; } } core.debug(` all patterns matched`); return true; } // equivalent to "Array.some()" but expanded for debugging and clarity function checkAny(changedFiles: string[], globs: string[]): boolean { const matchers = globs.map(g => new Minimatch(g)); core.debug(` checking "any" patterns`); for (const changedFile of changedFiles) { if (isMatch(changedFile, matchers)) { core.debug(` "any" patterns matched against ${changedFile}`); return true; } } core.debug(` "any" patterns did not match any files`); return false; } // equivalent to "Array.every()" but expanded for debugging and clarity function checkAll(changedFiles: string[], globs: string[]): boolean { const matchers = globs.map(g => new Minimatch(g)); core.debug(` checking "all" patterns`); for (const changedFile of changedFiles) { if (!isMatch(changedFile, matchers)) { core.debug(` "all" patterns did not match against ${changedFile}`); return false; } } core.debug(` "all" patterns matched all files`); return true; } function checkMatch(changedFiles: string[], matchConfig: MatchConfig): boolean { if (matchConfig.all !== undefined) { if (!checkAll(changedFiles, matchConfig.all)) { return false; } } if (matchConfig.any !== undefined) { if (!checkAny(changedFiles, matchConfig.any)) { return false; } } if (matchConfig.headBranch !== undefined) { if (!checkBranch(matchConfig.headBranch, 'head')) { return false; } } if (matchConfig.baseBranch !== undefined) { if (!checkBranch(matchConfig.baseBranch, 'base')) { return false; } } return true; } async function addLabels( client: ClientType, prNumber: number, labels: string[] ) { await client.rest.issues.addLabels({ owner: github.context.repo.owner, repo: github.context.repo.repo, issue_number: prNumber, labels: labels }); } async function removeLabels( client: ClientType, prNumber: number, labels: string[] ) { await Promise.all( labels.map(label => client.rest.issues.removeLabel({ owner: github.context.repo.owner, repo: github.context.repo.repo, issue_number: prNumber, name: label }) ) ); }