Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ jobs:
* @fakeorg/faketeam
* @${{ github.event.pull_request.user.login }}

- name: Test Local Action Where No Approvers Match
id: no_matching
uses: ./
continue-on-error: true
with:
always-succeed-before-approval: false
codeowners-path: .github/workflows/E2E_NONMATCHING_CODEOWNERS

- name: Fail if current_approver_is_not_owner succeeded
if: steps.no_matching.outcome == 'success'
run: |
echo "Error: current_approver_is_not_owner should have failed but succeeded" && exit 1

- name: Fail if files-missing-approver is not an array of strings
run: |
echo '${{ steps.no_matching.outputs.files-missing-approver }}' | jq -e 'map(select(type == "string"))' > /dev/null

- name: Fail if files-missing-approver is not an array longer than 0
run: |
if [ ! "$(echo '${{ steps.no_matching.outputs.files-missing-approver }}' | jq 'length')" -gt 0 ]; then
echo "Error: files-missing-approver should be an array longer than 0" && exit 1
fi

test-action-pr-review:
name: E2E Action Tests PR Review Event
runs-on: ubuntu-latest
Expand All @@ -75,15 +98,3 @@ jobs:
codeowners-contents: |
* @fakeorg/faketeam
* @${{ github.event.review.user.login }}

- name: Test Local Action Where Current Approver is Not Owner
id: current_approver_is_not_owner
uses: ./
continue-on-error: true
with:
codeowners-path: .github/workflows/E2E_NONMATCHING_CODEOWNERS

- name: Fail if current_approver_is_not_owner succeeded
if: steps.current_approver_is_not_owner.outcome == 'success'
run: |
echo "Error: current_approver_is_not_owner should have failed but succeeded" && exit 1
153 changes: 153 additions & 0 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,4 +984,157 @@ describe('main.ts', () => {
expect(createCommitStatus).not.toHaveBeenCalled()
})
})

describe('files-missing-approver output', () => {
it('sets output to an empty array when the check passes', async () => {
gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'frontend-dev' }, state: 'APPROVED' }]
}),
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
})
})
)

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})

it('sets output to the list of failing files when the check fails', async () => {
gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'bob' }, state: 'APPROVED' }]
}),
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'src/app.ts' }, { filename: 'src/utils.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
})
})
)

await run()

expect(core.setFailed).toHaveBeenCalled()
expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify(['src/app.ts', 'src/utils.ts'])
)
})

it('sets output to an empty array when not a pull request event', async () => {
gh.context.payload = {}
gh.getOctokit.mockReturnValue(gh.buildMockOctokit())

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})

it('sets output to an empty array when skipping due to no approvals', async () => {
gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: [] })
})
)

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})

it('sets output to an empty array when author is in ignore-authors', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'github-token') return 'fake-token'
if (name === 'ignore-authors') return 'alice'
return ''
})

gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'bob' }, state: 'APPROVED' }]
})
})
)

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})

it('sets output to an empty array when all changed files are ignored', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'github-token') return 'fake-token'
if (name === 'ignore-filepaths') return 'dist/**'
return ''
})

gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'bob' }, state: 'APPROVED' }]
}),
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'dist/bundle.js' }]
})
})
)

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})

it('sets output to an empty array when the CODEOWNERS file is empty', async () => {
gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'bob' }, state: 'APPROVED' }]
}),
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'src/foo.ts' }]
}),
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: { content: '', encoding: 'base64' } })
})
)

await run()

expect(core.setOutput).toHaveBeenCalledWith(
'files-missing-approver',
JSON.stringify([])
)
})
})
})
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ inputs:
required: false
default: ''

outputs:
files-missing-approver:
description: >-
JSON-encoded array of file paths that are missing a required approver. Use
fromJSON() in expressions to parse the value. Returns an empty array when
all files have required approvals or the check was skipped entirely.

runs:
using: node24
main: dist/index.js
54 changes: 53 additions & 1 deletion dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export async function run(): Promise<void> {

if (!context.payload.pull_request) {
core.info('Not a pull request event — skipping CODEOWNERS check.')
core.setOutput('files-missing-approver', JSON.stringify([]))
return
}

Expand Down Expand Up @@ -133,6 +134,7 @@ export async function run(): Promise<void> {
if (approvers.size === 0) {
if (alwaysSucceedBeforeApproval) {
core.info('No approvals found — skipping CODEOWNERS check.')
core.setOutput('files-missing-approver', JSON.stringify([]))
return
}
core.debug(
Expand All @@ -147,6 +149,7 @@ export async function run(): Promise<void> {
core.info(
`Author "${prAuthor}" is in ignore-authors — CODEOWNERS check passes.`
)
core.setOutput('files-missing-approver', JSON.stringify([]))
await setCommitStatus(
octokit,
owner,
Expand Down Expand Up @@ -182,6 +185,7 @@ export async function run(): Promise<void> {
core.info(
'All changed files are in ignore-filepaths — CODEOWNERS check passes.'
)
core.setOutput('files-missing-approver', JSON.stringify([]))
await setCommitStatus(
octokit,
owner,
Expand Down Expand Up @@ -227,6 +231,7 @@ export async function run(): Promise<void> {
}
if (!data.content) {
core.info('CODEOWNERS file is empty — CODEOWNERS check passes.')
core.setOutput('files-missing-approver', JSON.stringify([]))
await setCommitStatus(
octokit,
owner,
Expand Down Expand Up @@ -348,6 +353,10 @@ export async function run(): Promise<void> {
({ file, requiredOwners }) =>
` ${file}: requires approval from ${requiredOwners.join(' or ')}`
)
core.setOutput(
'files-missing-approver',
JSON.stringify(failures.map(({ file }) => file))
)
core.setFailed(
`CODEOWNERS check failed. The following files need approval:\n${lines.join('\n')}`
)
Expand All @@ -361,6 +370,7 @@ export async function run(): Promise<void> {
'CODEOWNERS check failed'
)
} else {
core.setOutput('files-missing-approver', JSON.stringify([]))
core.info('CODEOWNERS check passed.')
await setCommitStatus(
octokit,
Expand Down
Loading