diff --git a/__mocks__/rules/rule1.json b/__mocks__/rules/rule1.json index 7ae29217..110e6916 100644 --- a/__mocks__/rules/rule1.json +++ b/__mocks__/rules/rule1.json @@ -2,5 +2,6 @@ "customMessage": "This is a custom message for a rule", "users": ["eeny", "meeny", "miny", "moe"], "action": "comment", + "isMemberOf": ["counting_out_game"], "includes": ["*.ts"] } diff --git a/__tests__/index.HttpFailure.spec.ts b/__tests__/index.HttpFailure.spec.ts index ea23a1c6..7e5e93eb 100644 --- a/__tests__/index.HttpFailure.spec.ts +++ b/__tests__/index.HttpFailure.spec.ts @@ -4,8 +4,10 @@ import { env } from '../src/environment'; import { Event, HttpErrors } from '../src/util/constants'; import * as actions from '@actions/core'; import { Main, mockedInput } from './util/helpers'; +import event from '../__mocks__/event.json'; import getCommentsResponse from '../__mocks__/scenarios/get_comments.json'; -import { mockCompareCommits } from './util/mockGitHubRequest'; +import { mockCompareCommits, mockRequest } from './util/mockGitHubRequest'; + jest.mock('@actions/core'); jest.mock('../src/environment', () => { @@ -19,7 +21,16 @@ jest.mock('../src/environment', () => { }, }; }); +jest.mock('@actions/github', () => { + const workflowEvent = jest.requireActual('../__mocks__/event.json') as Event; + return { + context: { + actor: workflowEvent.repository.name, + repo: { owner: workflowEvent.repository.owner.login }, + }, + }; +}); describe('use-herald', () => { const getInput = actions.getInput as jest.Mock; const setFailed = actions.setFailed as jest.Mock; @@ -35,7 +46,7 @@ describe('use-herald', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires } = require(`../${env.GITHUB_EVENT_PATH}`) as Event; - const getGithubMock = () => + const getCompareCommitsMock = () => mockCompareCommits({ login, name, @@ -49,13 +60,23 @@ describe('use-herald', () => { }); const prIssue = 2; - const github = getGithubMock(); + const compareCommitsMock = getCompareCommitsMock(); + const membershipMock = mockRequest( + 'get', + `/orgs/${event.repository.owner.login}/teams/counting_out_game/memberships/${event.repository.name}`, + 200, + { + role: 'maintainer', + state: 'active', + url: `https://api.github.com/teams/1/memberships/${event.repository.owner.login}`, + } + ); - github + compareCommitsMock .get(`/repos/${login}/${name}/issues/${prIssue}/comments?page=1&per_page=100`) .reply(200, getCommentsResponse); - github + compareCommitsMock .post(`/repos/${login}/${name}/issues/2/comments`) .replyWithError({ message: 'Resource not accessible by integration', code: HttpErrors.RESOURCE_NOT_ACCESSIBLE }); @@ -73,6 +94,8 @@ describe('use-herald', () => { ], ] `); - expect(github.isDone()).toBe(true); + expect(compareCommitsMock.isDone()).toBe(true); + expect(membershipMock.isDone()).toBe(true); }); + }); diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index 8299dd5d..26e3453d 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -2,38 +2,52 @@ import { Props } from '../src'; import { Event } from '../src/util/constants'; import * as actions from '@actions/core'; -import { env } from '../src/environment'; import * as comment from '../src/comment'; -import { mockCompareCommits } from './util/mockGitHubRequest'; +import { mockCompareCommits, mockRequest, MockResponse } from './util/mockGitHubRequest'; +import getCompareCommitsResponse from '../__mocks__/scenarios/get_compare_commits.json'; import { Main, mockedInput } from './util/helpers'; +import event from '../__mocks__/event.json'; jest.mock('@actions/core'); jest.mock('../src/comment'); + jest.mock('../src/environment', () => { const { env } = jest.requireActual('../src/environment'); + const GITHUB_EVENT_PATH = '__mocks__/event.json'; + return { env: { ...env, - GITHUB_EVENT_PATH: '__mocks__/event.json', + GITHUB_EVENT_PATH, GITHUB_EVENT_NAME: 'pull_request', }, }; }); -const event = require(`../${env.GITHUB_EVENT_PATH}`) as Event; +jest.mock('@actions/github', () => { + const workflowEvent = jest.requireActual('../__mocks__/event.json') as Event; + + return { + context: { + actor: workflowEvent.repository.name, + repo: { owner: workflowEvent.repository.owner.login }, + }, + }; +}); const handleComment = comment.handleComment as jest.Mock; const setOutput = actions.setOutput as jest.Mock; const setFailed = actions.setFailed as jest.Mock; const getInput = actions.getInput as jest.Mock; -const getGithubMock = () => +const getCompareCommitsMock = (response?: MockResponse) => mockCompareCommits({ login: event.repository.owner.login, name: event.repository.name, base: event.pull_request.base.sha, head: event.pull_request.head.sha, + response, }); describe('use-herald-action', () => { @@ -55,13 +69,33 @@ describe('use-herald-action', () => { expect(setFailed).toHaveBeenCalled(); }); + it('should fail if the compareCommits response does not have files', async () => { + const changedRulesDirectory = { ...mockedInput, [Props.rulesLocation]: '__mocks__/required_rules/*.json' }; + getInput.mockImplementation((key: Partial) => { + return changedRulesDirectory[key]; + }); + + const compareCommitsMock = getCompareCommitsMock({ getCompareCommitsResponse, files: null }); + + const { main } = require('../src') as Main; + + await main(); + + expect(setFailed.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "There were no files returned from f95f852bd8fca8fcc58a9a2d6c842781e32a215e base and ec26c3e57ca3a959ca5aad62de7213c562f8c821 head", + ] + `); + expect(setOutput).not.toHaveBeenCalled(); + expect(compareCommitsMock.isDone()).toBe(true); + }); it('should fail if rules with errorLevel set to "error" does not match', async () => { const changedRulesDirectory = { ...mockedInput, [Props.rulesLocation]: '__mocks__/required_rules/*.json' }; getInput.mockImplementation((key: Partial) => { return changedRulesDirectory[key]; }); - const github = getGithubMock(); + const compareCommitsMock = getCompareCommitsMock(); const { main } = require('../src') as Main; @@ -73,7 +107,7 @@ describe('use-herald-action', () => { ] `); expect(setOutput).not.toHaveBeenCalled(); - expect(github.isDone()).toBe(true); + expect(compareCommitsMock.isDone()).toBe(true); }); it('should run normally (with dryRun: true)', async () => { @@ -81,7 +115,18 @@ describe('use-herald-action', () => { return mockedInput[key]; }); - const github = getGithubMock(); + const compareCommitsMock = getCompareCommitsMock(); + + const membershipMock = mockRequest( + 'get', + `/orgs/${event.repository.owner.login}/teams/counting_out_game/memberships/${event.repository.name}`, + 200, + { + role: 'maintainer', + state: 'active', + url: `https://api.github.com/teams/1/memberships/${event.repository.owner.login}`, + } + ); const { main } = require('../src') as Main; @@ -89,7 +134,8 @@ describe('use-herald-action', () => { expect(setFailed).not.toHaveBeenCalled(); expect(setOutput).toHaveBeenCalled(); - expect(github.isDone()).toBe(true); + expect(compareCommitsMock.isDone()).toBe(true); + expect(membershipMock.isDone()).toBe(true); }); it('should run the entire action', async () => { @@ -98,7 +144,18 @@ describe('use-herald-action', () => { return input[key]; }); - const github = getGithubMock(); + const compareCommitsMock = getCompareCommitsMock(); + + const membershipMock = mockRequest( + 'get', + `/orgs/${event.repository.owner.login}/teams/counting_out_game/memberships/${event.repository.name}`, + 200, + { + role: 'maintainer', + state: 'active', + url: `https://api.github.com/teams/1/memberships/${event.repository.owner.login}`, + } + ); const { main } = require('../src') as Main; @@ -119,9 +176,12 @@ describe('use-herald-action', () => { "*.ts", ], "includesInPatch": Array [], + "isMemberOf": Array [ + "counting_out_game", + ], "matched": true, "name": "rule1.json", - "path": "${env.GITHUB_WORKSPACE}/__mocks__/rules/rule1.json", + "path": "/Users/gfrigerio/base/use-herald/__mocks__/rules/rule1.json", "teams": Array [], "users": Array [ "eeny", @@ -135,7 +195,8 @@ describe('use-herald-action', () => { ] `); - expect(github.isDone()).toBe(true); + expect(compareCommitsMock.isDone()).toBe(true); + expect(membershipMock.isDone()).toBe(true); }); it('should run the entire action (no rules found)', async () => { @@ -149,7 +210,7 @@ describe('use-herald-action', () => { return input[key]; }); - const github = getGithubMock(); + const compareCommitsMock = getCompareCommitsMock(); const { main } = require('../src') as Main; await main(); @@ -157,6 +218,6 @@ describe('use-herald-action', () => { expect(handleComment).not.toHaveBeenCalled(); expect(setFailed.mock.calls).toMatchInlineSnapshot('Array []'); expect(setOutput.mock.calls).toMatchSnapshot(); - expect(github.isDone()).toBe(true); + expect(compareCommitsMock.isDone()).toBe(true); }); }); diff --git a/__tests__/rules.spec.ts b/__tests__/rules.spec.ts index f8afbc03..d101f288 100644 --- a/__tests__/rules.spec.ts +++ b/__tests__/rules.spec.ts @@ -4,7 +4,7 @@ import * as fg from 'fast-glob'; import { RuleActions, Rules } from '../src/rules'; import { unMockConsole, mockConsole } from './util/helpers'; -import { Event } from '../src/util/constants'; +import { Event, OctokitClient } from '../src/util/constants'; import eventJSON from '../__mocks__/event.json'; import alterEventJSON from '../__mocks__/event_should_work.json'; @@ -65,7 +65,7 @@ describe('rules', () => { }; const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot('MatchingRules []'); + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot('MatchingRules []'); }); it('Matches includes and eventJsonPath (using contains)', async () => { @@ -92,7 +92,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, alterEvent)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, alterEvent, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -138,7 +138,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event, [])).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient, [])).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -189,7 +189,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -237,7 +237,7 @@ describe('rules', () => { const rules = new Rules(...rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -286,7 +286,7 @@ describe('rules', () => { const rules = new Rules(rule); expect( - await rules.getMatchingRules(files, event, [ + await rules.getMatchingRules(files, event, {} as OctokitClient, [ '@@ -132,7 +132,7 @@ module simon @@ -1000,7 +1000,7 @@ module gago', '@@ -132,7 +132,7 @@ module jon @@ -1000,7 +1000,7 @@ module heart', ]) @@ -321,7 +321,7 @@ describe('rules', () => { const rules = new Rules(rule); expect( - await rules.getMatchingRules(files, event, [ + await rules.getMatchingRules(files, event, {} as OctokitClient, [ '@@ -132,7 +132,7 @@ module simon @@ -1000,7 +1000,7 @@ module gago', '@@ -132,7 +132,7 @@ module jon @@ -1000,7 +1000,7 @@ module heart', ]) @@ -376,7 +376,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -415,7 +415,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -454,7 +454,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, botEvent)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, botEvent, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -496,7 +496,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchObject([]); + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchObject([]); }); it('matches includes && eventJsonPath in the same rule', async () => { const files = [ @@ -516,7 +516,7 @@ describe('rules', () => { const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -558,7 +558,7 @@ describe('rules', () => { }; const rules = new Rules(rule); - expect(await rules.getMatchingRules(files, event)).toMatchInlineSnapshot(` + expect(await rules.getMatchingRules(files, event, {} as OctokitClient)).toMatchInlineSnapshot(` MatchingRules [ Object { "action": "comment", @@ -608,6 +608,7 @@ describe('rules', () => { "excludes": Array [], "includes": Array [], "includesInPatch": Array [], + "isMemberOf": Array [], "labels": Array [ "enhancement", ], @@ -699,6 +700,7 @@ describe('rules', () => { "*.ts", ], "includesInPatch": Array [], + "isMemberOf": Array [], "name": "rule1.json", "path": "/some/rule1.json", "teams": Array [], @@ -718,6 +720,7 @@ describe('rules', () => { "*.ts", ], "includesInPatch": Array [], + "isMemberOf": Array [], "name": "rule2.json", "path": "/some/rule2.json", "teams": Array [ @@ -734,6 +737,7 @@ describe('rules', () => { "*.ts", ], "includesInPatch": Array [], + "isMemberOf": Array [], "name": "rule3.json", "path": "/some/rule3.json", "teams": Array [ @@ -750,6 +754,7 @@ describe('rules', () => { "excludes": Array [], "includes": Array [], "includesInPatch": Array [], + "isMemberOf": Array [], "name": "rule4.json", "path": "/some/rule4.json", "teams": Array [], @@ -769,6 +774,7 @@ describe('rules', () => { "*.ts", ], "includesInPatch": Array [], + "isMemberOf": Array [], "labels": Array [ "feature-label", "another-label", diff --git a/__tests__/util/mockGitHubRequest.ts b/__tests__/util/mockGitHubRequest.ts index 0a797f73..fe19f2c8 100644 --- a/__tests__/util/mockGitHubRequest.ts +++ b/__tests__/util/mockGitHubRequest.ts @@ -8,7 +8,7 @@ export type CallbackRequest = ( body: { target_url: string; description: string; state: string; context: string }, cb: (arg0: unknown, arg1: unknown) => void ) => void; -type MockResponse = Record | Record[] | CallbackRequest; +export type MockResponse = Record | Record[] | CallbackRequest; type MockRequest = ( action: 'post' | 'get', url: string, @@ -22,9 +22,29 @@ type CompareURLInput = { login: string; name: string; base: string; head: string const compareURL = (options: CompareURLInput) => `/repos/${options.login}/${options.name}/compare/${options.base}...${options.head}`; + export const mockCompareCommits = (options: CompareURLInput & { response?: MockResponse }): ReturnType => { return mockRequest('get', compareURL(options), 200, options.response || getCompareCommitsResponse); }; + +type mockTeamMembershipInput = { + org: string, + team_slug: string, + username: string +}; +type mockTeamMembershipResponse = { + url: string, + role: string, + state: string +} + +// "https://api.github.com/orgs/gagoar/teams/counting_out_game/memberships/example_repo" +const teamMembershipURL = (options: mockTeamMembershipInput) => { + return `/orgs/${options.org}/teams/${options.team_slug}/memberships/${options.username}` +}; +export const mockTeamMembership = (options: mockTeamMembershipInput, response: mockTeamMembershipResponse): ReturnType => { + return mockRequest('get', teamMembershipURL(options), 200, response); +} export const mockRequestWithError = ( action: 'get' | 'post', url: string, diff --git a/package-lock.json b/package-lock.json index ed1b3b10..a9549f72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,98 @@ "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.4.0.tgz", "integrity": "sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg==" }, + "@actions/github": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.0.0.tgz", + "integrity": "sha512-QvE9eAAfEsS+yOOk0cylLBIO/d6WyWIOvsxxzdrPFaud39G6BOkUwScXZn1iBzQzHyu9SBkkLSWlohDWdsasAQ==", + "requires": { + "@actions/http-client": "^1.0.11", + "@octokit/core": "^3.4.0", + "@octokit/plugin-paginate-rest": "^2.13.3", + "@octokit/plugin-rest-endpoint-methods": "^5.1.1" + }, + "dependencies": { + "@octokit/core": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.5.1.tgz", + "integrity": "sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw==", + "requires": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.0", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-9.7.0.tgz", + "integrity": "sha512-TUJ16DJU8mekne6+KVcMV5g6g/rJlrnIKn7aALG9QrNpnEipFc1xjoarh0PKaAWf2Hf+HwthRKYt+9mCm5RsRg==" + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.8.0.tgz", + "integrity": "sha512-qeLZZLotNkoq+it6F+xahydkkbnvSK0iDjlXFo3jNTB+Ss0qIbYQb9V/soKLMkgGw8Q2sHjY5YEXiA47IVPp4A==", + "requires": { + "@octokit/types": "^6.25.0", + "deprecation": "^2.3.1" + }, + "dependencies": { + "@octokit/types": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.25.0.tgz", + "integrity": "sha512-bNvyQKfngvAd/08COlYIN54nRgxskmejgywodizQNyiKoXmWRAjKup2/LYwm+T9V0gsKH6tuld1gM0PzmOiB4Q==", + "requires": { + "@octokit/openapi-types": "^9.5.0" + } + } + } + }, + "@octokit/request": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.1.tgz", + "integrity": "sha512-Ls2cfs1OfXaOKzkcxnqw5MR6drMA/zWX/LIS/p8Yjdz7QKTPQLMsB3R+OvoxE6XnXeXEE2X7xe4G4l4X0gRiKQ==", + "requires": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.1", + "universal-user-agent": "^6.0.0" + }, + "dependencies": { + "@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "requires": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/types": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.25.0.tgz", + "integrity": "sha512-bNvyQKfngvAd/08COlYIN54nRgxskmejgywodizQNyiKoXmWRAjKup2/LYwm+T9V0gsKH6tuld1gM0PzmOiB4Q==", + "requires": { + "@octokit/openapi-types": "^9.5.0" + } + } + } + } + } + }, + "@actions/http-client": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz", + "integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==", + "requires": { + "tunnel": "0.0.6" + } + }, "@babel/code-frame": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", @@ -4350,6 +4442,11 @@ "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "dev": true }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, "is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8162,6 +8259,11 @@ } } }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", diff --git a/package.json b/package.json index 76e712cf..84aaa762 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "author": "Gago ", "dependencies": { "@actions/core": "1.4.0", + "@actions/github": "^5.0.0", "@octokit/rest": "18.5.2", "debug": "4.3.2", "envalid": "6.0.2", diff --git a/src/index.ts b/src/index.ts index e8b4b8c0..f7604505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { getInput, setOutput, setFailed } from '@actions/core'; import { Rules, RuleActions, allRequiredRulesHaveMatched, MatchingRule, Rule } from './rules'; -import { Event, OUTPUT_NAME, SUPPORTED_EVENT_TYPES, RuleFile } from './util/constants'; +import { Event, OUTPUT_NAME, SUPPORTED_EVENT_TYPES, RuleFile, OctokitClient } from './util/constants'; import { logger } from './util/debug'; import { env } from './environment'; @@ -35,7 +35,7 @@ export type ActionInput = { files: RuleFile[]; }; export type ActionMapInput = ( - client: InstanceType, + client: OctokitClient, options: ActionInput, requestConcurrency?: number ) => Promise; @@ -102,9 +102,14 @@ export const main = async (): Promise => { repo, }); + if (!files) { + throw new Error(`There were no files returned from ${base} base and ${headSha} head`); + } + const matchingRules = await rules.getMatchingRules( files, event, + client, files.reduce((memo, { patch }) => (patch ? [...memo, patch] : memo), [] as string[]) ); diff --git a/src/isMemberOf.ts b/src/isMemberOf.ts new file mode 100644 index 00000000..07b11346 --- /dev/null +++ b/src/isMemberOf.ts @@ -0,0 +1,41 @@ +import github from '@actions/github'; +import PQueue from 'p-queue'; +import { catchHandler } from './util/catchHandler'; +import { OctokitClient } from './util/constants'; +import type { RestEndpointMethodTypes } from '@octokit/rest'; +import { logger } from './util/debug'; + +const debug = logger('isMemberOf'); + +const ACTIVE_STATE = 'active'; + +type GetMembershipForUserInOrg = RestEndpointMethodTypes['teams']['getMembershipForUserInOrg']['response']; + +const isGetMembershipForUserInOrg = (response: Record): response is GetMembershipForUserInOrg => !!response?.data +export const handleMembership = async ( + client: OctokitClient, + isMemberOf: string[] = [], + requestConcurrency = 2 +): Promise => { + const { repo, actor } = github.context; + + const queue = new PQueue({ concurrency: requestConcurrency }); + const membershipChecks = isMemberOf.map((team) => { + return { + org: repo.owner, + team_slug: team, + username: actor, + }; + }); + debug( + `We will check membership of ${actor} in the following teams ${membershipChecks.map(({ team_slug: team }) => team)}` + ); + + const results = await Promise.all( + membershipChecks.map((membership) => queue.add(() => client.teams.getMembershipForUserInOrg(membership))) + ).catch(catchHandler(debug)); + + return (results as Record[]).some((response: Record) => { + return isGetMembershipForUserInOrg(response) && response.data.state == ACTIVE_STATE; + }); +}; diff --git a/src/rules.ts b/src/rules.ts index ecb54c78..47bdd562 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -2,7 +2,7 @@ import { sync } from 'fast-glob'; import { basename } from 'path'; import { memo } from './util/memoizeDecorator'; -import { Event, MATCH_RULE_CONCURRENCY, RuleFile } from './util/constants'; +import { Event, MATCH_RULE_CONCURRENCY, OctokitClient, RuleFile } from './util/constants'; import { env } from './environment'; import minimatch from 'minimatch'; @@ -15,6 +15,7 @@ import PQueue from 'p-queue'; import { catchHandler } from './util/catchHandler'; import { isMatchingRule } from './rules.guard'; import { isValidRawRule, RawRule } from './util/isValidRawRule'; +import { handleMembership } from './isMemberOf'; const debug = logger('rules'); @@ -35,6 +36,8 @@ export enum RuleMatchers { includesInPatch = 'includesInPatch', eventJsonPath = 'eventJsonPath', includes = 'includes', + + isMemberOf = 'isMemberOf', } // Nothings lost, nothings added except string indexes @@ -62,6 +65,7 @@ export interface Rule { action: keyof typeof RuleActions; includes?: string[]; excludes?: string[]; + isMemberOf?: string[]; includesInPatch?: string[]; eventJsonPath?: string[]; customMessage?: string; @@ -85,6 +89,7 @@ const sanitize = (content: RawRule & StringIndexSignatureInterface): Rule => { excludes: makeArray(rule.excludes), includesInPatch: makeArray(rule.includesInPatch), eventJsonPath: makeArray(rule.eventJsonPath), + isMemberOf: makeArray(rule.isMemberOf), }; }; @@ -181,23 +186,34 @@ const handleEventJsonPath: HandleEventJsonPath = ({ event, patterns }) => { return false; }; -type Matcher = (rule: Rule, options: { event: Event; patch: string[]; fileNames: string[] }) => Promise; +type Matcher = ( + rule: Rule, + options: { event: Event; patch: string[]; fileNames: string[]; client: OctokitClient } +) => Promise; const matchers: Record = { - [RuleMatchers.includes]: async (rule, { fileNames }) => - handleIncludeExcludeFiles({ includes: rule.includes, excludes: rule.excludes, fileNames }), - [RuleMatchers.eventJsonPath]: async (rule, { event }) => handleEventJsonPath({ patterns: rule.eventJsonPath, event }), - [RuleMatchers.includesInPatch]: async (rule, { patch }) => - handleIncludesInPatch({ patterns: rule.includesInPatch, patch }), + [RuleMatchers.isMemberOf]: async ({ isMemberOf }, { client }) => handleMembership(client, isMemberOf), + [RuleMatchers.includes]: async ({ includes, excludes }, { fileNames }) => + handleIncludeExcludeFiles({ includes, excludes, fileNames }), + [RuleMatchers.eventJsonPath]: async ({ eventJsonPath }, { event }) => + handleEventJsonPath({ patterns: eventJsonPath, event }), + [RuleMatchers.includesInPatch]: async ({ includesInPatch }, { patch }) => + handleIncludesInPatch({ patterns: includesInPatch, patch }), }; type KeyMatchers = keyof typeof RuleMatchers; const isMatch: Matcher = async (rule, options) => { const keyMatchers = Object.keys(RuleMatchers) as KeyMatchers[]; + const matches = keyMatchers - .filter((matcher) => rule[matcher]?.length) - .map((matcher) => matchers[matcher](rule, options)); + .filter((matcher) => (Array.isArray(rule[matcher]) ? rule[matcher]?.length : false)) + .map((matcher) => { + const result = matchers[matcher](rule, options); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result as any)._invokedMatcher = matcher; + return result; + }); debug('isMatch:', { rule, matches }); @@ -230,8 +246,13 @@ export class Rules extends Array { return new Rules(...rules); } - async getMatchingRules(files: RuleFile[], event: Event, patchContent?: string[]): Promise { - return MatchingRules.load(this, files, event, patchContent); + async getMatchingRules( + files: RuleFile[], + event: Event, + client: OctokitClient, + patchContent?: string[] + ): Promise { + return MatchingRules.load(this, client, files, event, patchContent); } } @@ -251,6 +272,7 @@ class MatchingRules extends Array { static async load( rules: Rules, + client: OctokitClient, files: RuleFile[], event: Event, patchContent: string[] = [] @@ -260,7 +282,7 @@ class MatchingRules extends Array { const matchingRules = rules.map((rule) => { return queue.add(async () => { - const matched = await isMatch(rule, { event, patch: patchContent, fileNames }); + const matched = await isMatch(rule, { event, patch: patchContent, fileNames, client }); return { ...rule, matched } as MatchingRule; }); diff --git a/src/util/constants.ts b/src/util/constants.ts index dea733bb..86baa411 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,4 +1,4 @@ -import { RestEndpointMethodTypes } from '@octokit/rest'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; export const maxPerPage = 100; export const OUTPUT_NAME = 'appliedRules'; @@ -20,6 +20,8 @@ export enum SUPPORTED_EVENT_TYPES { PULL_REQUEST_TARGET = 'pull_request_target', push = 'push', } + +export type OctokitClient = InstanceType; interface Commit { sha: string; }