diff --git a/__tests__/labeler.test.ts b/__tests__/labeler.test.ts new file mode 100644 index 0000000..d7195cd --- /dev/null +++ b/__tests__/labeler.test.ts @@ -0,0 +1,29 @@ +import { checkGlobs } from '../src/labeler' + +import * as core from "@actions/core"; + +jest.mock("@actions/core"); + +beforeAll(() => { + jest.spyOn(core, "getInput").mockImplementation((name, options) => { + return jest.requireActual("@actions/core").getInput(name, options); + }); +}); + +const matchConfig = [{ any: ["*.txt"] }]; + +describe('checkGlobs', () => { + it('returns true when our pattern does match changed files', () => { + const changedFiles = ["foo.txt", "bar.txt"]; + const result = checkGlobs(changedFiles, matchConfig); + + expect(result).toBeTruthy(); + }); + + it('returns false when our pattern does not match changed files', () => { + const changedFiles = ["foo.docx"]; + const result = checkGlobs(changedFiles, matchConfig); + + expect(result).toBeFalsy(); + }); +}); diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts deleted file mode 100644 index 42af5f1..0000000 --- a/__tests__/main.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -describe("TODO - Add a test suite", () => { - it("TODO - Add a test", async () => {}); -}); diff --git a/src/labeler.ts b/src/labeler.ts new file mode 100644 index 0000000..f7054a4 --- /dev/null +++ b/src/labeler.ts @@ -0,0 +1,259 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import * as yaml from "js-yaml"; +import { Minimatch, IMinimatch } from "minimatch"; + +interface MatchConfig { + all?: string[]; + any?: string[]; +} + +type StringOrMatchConfig = string | MatchConfig; + +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 = new github.GitHub(token); + + const { data: pullRequest } = await client.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) { + 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: github.GitHub, + prNumber: number +): Promise { + const listFilesOptions = client.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 => f.filename); + + core.debug("found changed files:"); + for (const file of changedFiles) { + core.debug(" " + file); + } + + return changedFiles; +} + +async function getLabelGlobs( + client: github.GitHub, + configurationPath: string +): Promise> { + const configurationContent: string = await fetchContent( + client, + configurationPath + ); + + // loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`: + const configObject: any = yaml.safeLoad(configurationContent); + + // transform `any` => `Map` or throw if yaml is malformed: + return getLabelGlobMapFromObject(configObject); +} + +async function fetchContent( + client: github.GitHub, + repoPath: string +): Promise { + const response: any = await client.repos.getContents({ + 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 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; + } + } + + return true; +} + +async function addLabels( + client: github.GitHub, + prNumber: number, + labels: string[] +) { + await client.issues.addLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + labels: labels + }); +} + +async function removeLabels( + client: github.GitHub, + prNumber: number, + labels: string[] +) { + await Promise.all( + labels.map(label => + client.issues.removeLabel({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + name: label + }) + ) + ); +} diff --git a/src/main.ts b/src/main.ts index 971fd99..d9d6212 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,261 +1,3 @@ -import * as core from "@actions/core"; -import * as github from "@actions/github"; -import * as yaml from "js-yaml"; -import { Minimatch, IMinimatch } from "minimatch"; - -interface MatchConfig { - all?: string[]; - any?: string[]; -} - -type StringOrMatchConfig = string | MatchConfig; - -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 = new github.GitHub(token); - - const { data: pullRequest } = await client.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) { - 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: github.GitHub, - prNumber: number -): Promise { - const listFilesOptions = client.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 => f.filename); - - core.debug("found changed files:"); - for (const file of changedFiles) { - core.debug(" " + file); - } - - return changedFiles; -} - -async function getLabelGlobs( - client: github.GitHub, - configurationPath: string -): Promise> { - const configurationContent: string = await fetchContent( - client, - configurationPath - ); - - // loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`: - const configObject: any = yaml.safeLoad(configurationContent); - - // transform `any` => `Map` or throw if yaml is malformed: - return getLabelGlobMapFromObject(configObject); -} - -async function fetchContent( - client: github.GitHub, - repoPath: string -): Promise { - const response: any = await client.repos.getContents({ - 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 config; -} - -function printPattern(matcher: IMinimatch): string { - return (matcher.negate ? "!" : "") + matcher.pattern; -} - -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; - } - } - - return true; -} - -async function addLabels( - client: github.GitHub, - prNumber: number, - labels: string[] -) { - await client.issues.addLabels({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: prNumber, - labels: labels - }); -} - -async function removeLabels( - client: github.GitHub, - prNumber: number, - labels: string[] -) { - await Promise.all( - labels.map(label => - client.issues.removeLabel({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: prNumber, - name: label - }) - ) - ); -} +import { run } from "./labeler"; run();