Skip to content
Draft
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
94 changes: 31 additions & 63 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ jest.unstable_mockModule('@actions/github', () => gh)
// mocks are used in place of any actual dependencies.
const { run } = await import('../src/main.js')

/** Build a base64-encoded CODEOWNERS content string. */
function b64(content: string): string {
return Buffer.from(content).toString('base64')
}

const BASE_CODEOWNERS = '*.ts @org/frontend\n* @org/default\n'

describe('main.ts', () => {
Expand Down Expand Up @@ -178,7 +173,7 @@ describe('main.ts', () => {
}),
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: { content: '', encoding: 'base64' } })
.mockResolvedValue({ data: '' })
})
)

Expand All @@ -199,10 +194,10 @@ describe('main.ts', () => {
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'src/foo.ts' }]
}),
// A directory response is returned as an array.
// With the raw accept header, GitHub returns a 422 error for directory paths.
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: [{ name: 'CODEOWNERS' }] })
.mockRejectedValue(new Error('422 Unprocessable Entity'))
})
)

Expand All @@ -213,26 +208,29 @@ describe('main.ts', () => {
)
})

it('fails when the CODEOWNERS file is too large to decode (encoding none)', async () => {
it('succeeds when the CODEOWNERS file is larger than 1 MB', async () => {
// With the raw accept header, files up to 100 MB are returned as raw strings.
const largeCODEOWNERS = '*.ts @frontend-dev\n'.repeat(60_000)

gh.getOctokit.mockReturnValue(
gh.buildMockOctokit({
listReviews: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ user: { login: 'bob' }, state: 'APPROVED' }]
data: [{ user: { login: 'frontend-dev' }, state: 'APPROVED' }]
}),
listFiles: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ filename: 'src/foo.ts' }]
}),
// Files over ~1 MB come back with empty content and encoding "none".
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: { content: '', encoding: 'none' } })
.mockResolvedValue({ data: largeCODEOWNERS })
})
)

await run()

expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch CODEOWNERS file')
expect(core.setFailed).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('CODEOWNERS check passed')
)
})

Expand All @@ -248,7 +246,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @Frontend-Dev\n'), encoding: 'base64' }
data: '*.ts @Frontend-Dev\n'
})
})
)
Expand All @@ -271,7 +269,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @MyOrg/Frontend\n'), encoding: 'base64' }
data: '*.ts @MyOrg/Frontend\n'
}),
listMembersInOrg: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ login: 'team-member' }]
Expand All @@ -297,7 +295,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
data: '*.ts @frontend-dev\n'
})
})
)
Expand All @@ -320,10 +318,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
data: BASE_CODEOWNERS
})
})
)
Expand All @@ -346,10 +341,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64('*.ts @alice\n'),
encoding: 'base64'
}
data: '*.ts @alice\n'
})
})
)
Expand Down Expand Up @@ -379,10 +371,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64('*.ts @myorg/frontend\n'),
encoding: 'base64'
}
data: '*.ts @myorg/frontend\n'
}),
listMembersInOrg: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ login: 'team-member' }, { login: 'other-member' }]
Expand All @@ -408,10 +397,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64('*.ts @myorg/frontend\n'),
encoding: 'base64'
}
data: '*.ts @myorg/frontend\n'
}),
listMembersInOrg: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: [{ login: 'team-member' }]
Expand All @@ -436,10 +422,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64('*.ts @myorg/nonexistent-team\n'),
encoding: 'base64'
}
data: '*.ts @myorg/nonexistent-team\n'
}),
listMembersInOrg: jest
.fn<() => Promise<unknown>>()
Expand Down Expand Up @@ -472,10 +455,7 @@ describe('main.ts', () => {
]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64('*.ts @myorg/frontend\n'),
encoding: 'base64'
}
data: '*.ts @myorg/frontend\n'
}),
listMembersInOrg
})
Expand All @@ -502,10 +482,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
data: BASE_CODEOWNERS
})
})
)
Expand Down Expand Up @@ -551,7 +528,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
data: '*.ts @frontend-dev\n'
})
})
)
Expand Down Expand Up @@ -649,7 +626,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
data: '*.ts @frontend-dev\n'
}),
createCommitStatus
})
Expand Down Expand Up @@ -704,7 +681,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
data: '*.ts @frontend-dev\n'
}),
createCommitStatus
})
Expand Down Expand Up @@ -803,7 +780,7 @@ describe('main.ts', () => {
}),
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: { content: '', encoding: 'base64' } }),
.mockResolvedValue({ data: '' }),
createCommitStatus
})
)
Expand Down Expand Up @@ -836,10 +813,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
data: BASE_CODEOWNERS
}),
createCommitStatus
})
Expand Down Expand Up @@ -873,10 +847,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
data: BASE_CODEOWNERS
}),
createCommitStatus
})
Expand Down Expand Up @@ -996,7 +967,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: { content: b64('*.ts @frontend-dev\n'), encoding: 'base64' }
data: '*.ts @frontend-dev\n'
})
})
)
Expand All @@ -1019,10 +990,7 @@ describe('main.ts', () => {
data: [{ filename: 'src/app.ts' }, { filename: 'src/utils.ts' }]
}),
getContent: jest.fn<() => Promise<unknown>>().mockResolvedValue({
data: {
content: b64(BASE_CODEOWNERS),
encoding: 'base64'
}
data: BASE_CODEOWNERS
})
})
)
Expand Down Expand Up @@ -1125,7 +1093,7 @@ describe('main.ts', () => {
}),
getContent: jest
.fn<() => Promise<unknown>>()
.mockResolvedValue({ data: { content: '', encoding: 'base64' } })
.mockResolvedValue({ data: '' })
})
)

Expand Down
27 changes: 10 additions & 17 deletions 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.

29 changes: 10 additions & 19 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,25 +211,16 @@ export async function run(): Promise<void> {
owner,
repo,
path: codeownersPath,
ref: headSha
ref: headSha,
headers: {
accept: 'application/vnd.github.raw'
}
})
const responseData = response.data
// A directory/symlink/submodule response (or anything that is not a
// single file) cannot be a CODEOWNERS file. Treating it as an empty
// file would silently disable the check, so fail loudly instead.
if (Array.isArray(responseData)) {
throw new Error(`path "${codeownersPath}" is a directory, not a file`)
}
const data = responseData as { content?: string; encoding?: string }
// Files larger than ~1 MB are returned with an "encoding" of "none" and
// empty content. Silently passing in that case would disable the check,
// so surface it as a failure and direct the user to codeowners-contents.
if (data.encoding && data.encoding !== 'base64') {
throw new Error(
`unsupported content encoding "${data.encoding}" (the file may exceed the 1 MB API limit — use the codeowners-contents input instead)`
)
}
if (!data.content) {
// With the raw accept header the GitHub API returns the file contents
// directly as a string, bypassing the ~1 MB base64-encoding cap
// (supports files up to 100 MB).
const codeownersRaw = response.data as unknown as string
if (!codeownersRaw) {
core.info('CODEOWNERS file is empty — CODEOWNERS check passes.')
core.setOutput('files-missing-approver', JSON.stringify([]))
await setCommitStatus(
Expand All @@ -243,7 +234,7 @@ export async function run(): Promise<void> {
)
return
}
codeownersContent = Buffer.from(data.content, 'base64').toString('utf8')
codeownersContent = codeownersRaw
} catch (error: unknown) {
const message = `Failed to fetch CODEOWNERS file at "${codeownersPath}" with error: ${errorToString(error)}`
core.setFailed(message)
Expand Down
Loading