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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All processing is done within Github Actions, no data is sent to an external ser
| `GITHUB_BASE_URL` | **no** | `https://api.github.com` | Base URL for GitHub API. Required for GitHub Enterprise Server or Cloud. |
| `COVERAGE_FILE_PATH` | **yes** | - | Location of coverage file that was generated |
| `COVERAGE_FORMAT` | **no** | `lcov` | Format of coverage file. May be `lcov`, `clover`, or `go` |
| `FAIL_ON_UNCOVERED_LINES` | **no** | `false` | Fail the workflow and the check run if any line added in the pull request is not covered by tests |
| `DEBUG` | **no** | - | Log debugging information. Comma-separated list of possible values `coverage`, `pr_lines_added` |

## Usage
Expand All @@ -38,6 +39,25 @@ coverage format with values appropriate for your repo:
COVERAGE_FORMAT: "lcov"
```

## Blocking pull requests that introduce coverage gaps

By default the action only reports. Set `FAIL_ON_UNCOVERED_LINES` to fail the step and
mark the `Annotate` check run as failed whenever a line added by the pull request is not
covered by tests:

```yaml
- name: Code Coverage Annotation
uses: ggilder/codecoverage@v1
with:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
COVERAGE_FILE_PATH: "./coverage/lcov.info"
FAIL_ON_UNCOVERED_LINES: "true"
```

With the flag enabled, annotations are posted at `failure` level instead of `warning`.
To actually prevent merging, add the `Annotate` check run to your branch protection
rules as a required status check.

## Usage with GitHub Enterprise Server or Cloud

You need to the `GITHUB_BASE_URL` input to point to your API endpoint to use this action with GitHub Enterprise Server or Cloud. For example:
Expand Down
118 changes: 117 additions & 1 deletion __tests__/utils/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import {test, expect} from 'vitest'
import {test, expect, vi, beforeAll} from 'vitest'
import {GithubUtil} from '../../src/utils/github'

beforeAll(function () {
// github.context.repo reads this
process.env.GITHUB_REPOSITORY = 'test-owner/test-repo'
})

function stubClient(githubUtil: GithubUtil) {
const response = {
status: 200,
data: {id: 42, output: {annotations_url: 'https://example.com/annotations'}}
}
const create = vi.fn().mockResolvedValue(response)
const update = vi.fn().mockResolvedValue(response)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(githubUtil as any).client = {rest: {checks: {create, update}}}
return {create, update}
}

function fakeAnnotations(count: number) {
return Array.from({length: count}, (_, i) => ({
path: 'file1.txt',
start_line: i + 1,
end_line: i + 1,
annotation_level: 'warning' as const,
message: 'This line is not covered by a test'
}))
}

test('github init successfully', async function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')
expect(githubUtil).toBeInstanceOf(GithubUtil)
Expand Down Expand Up @@ -69,4 +96,93 @@ test('build annotations', function () {
])
})

test('annotate defaults to a success conclusion', async function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')
const {create, update} = stubClient(githubUtil)

await githubUtil.annotate({
referenceCommitHash: 'abc123',
annotations: fakeAnnotations(1)
})

expect(create).toHaveBeenCalledTimes(1)
expect(update).not.toHaveBeenCalled()
expect(create.mock.calls[0][0]).toMatchObject({
status: 'completed',
conclusion: 'success'
})
})

test('annotate forwards a failure conclusion', async function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')
const {create} = stubClient(githubUtil)

await githubUtil.annotate({
referenceCommitHash: 'abc123',
annotations: fakeAnnotations(1),
conclusion: 'failure'
})

expect(create.mock.calls[0][0]).toMatchObject({
status: 'completed',
conclusion: 'failure'
})
})

test('annotate only concludes on the final chunk', async function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')
const {create, update} = stubClient(githubUtil)

await githubUtil.annotate({
referenceCommitHash: 'abc123',
annotations: fakeAnnotations(120),
conclusion: 'failure'
})

expect(create).toHaveBeenCalledTimes(1)
expect(update).toHaveBeenCalledTimes(2)
expect(create.mock.calls[0][0]).toMatchObject({status: 'in_progress'})
expect(create.mock.calls[0][0].conclusion).toBeUndefined()
expect(update.mock.calls[0][0]).toMatchObject({
check_run_id: 42,
status: 'in_progress'
})
expect(update.mock.calls[0][0].conclusion).toBeUndefined()
expect(update.mock.calls[1][0]).toMatchObject({
check_run_id: 42,
conclusion: 'failure'
})
})

test('annotate skips the API when there are no annotations', async function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')
const {create, update} = stubClient(githubUtil)

expect(
await githubUtil.annotate({referenceCommitHash: 'abc123', annotations: []})
).toBe(0)
expect(create).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
})

test('build annotations at failure level', function () {
const githubUtil = new GithubUtil('1234', 'https://api.github.com')

const annotations = githubUtil.buildAnnotations(
[{fileName: 'file1.txt', missingLineNumbers: [132]}],
{'file1.txt': [{end_line: 139, start_line: 132}]},
'failure'
)

expect(annotations).toEqual([
{
path: 'file1.txt',
start_line: 132,
end_line: 132,
annotation_level: 'failure',
message: 'This line is not covered by a test'
}
])
})

// @todo test for rest of github class
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ inputs:
COVERAGE_FORMAT:
required: false
description: 'Format of coverage file. May be `lcov`, `clover`, or `go`.'
FAIL_ON_UNCOVERED_LINES:
required: false
description: 'Fail the workflow and the check run if any line added in the pull request is not covered by tests.'
default: 'false'
DEBUG:
required: false
description: "Log debugging information. Comma-separated list of possible values `coverage`, `pr_lines_added`"
Expand Down
20 changes: 15 additions & 5 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.

20 changes: 18 additions & 2 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export async function play(): Promise<void> {
)
}

// getBooleanInput throws on an empty string, which happens when the action
// is invoked without action.yml defaults applied.
const FAIL_ON_UNCOVERED_LINES = core.getInput('FAIL_ON_UNCOVERED_LINES')
? core.getBooleanInput('FAIL_ON_UNCOVERED_LINES')
: false

const debugOpts = {}
const DEBUG = core.getInput('DEBUG')
if (DEBUG) {
Expand Down Expand Up @@ -91,15 +97,25 @@ export async function play(): Promise<void> {
}
const annotations = githubUtil.buildAnnotations(
coverageByFile,
pullRequestFiles
pullRequestFiles,
FAIL_ON_UNCOVERED_LINES ? 'failure' : 'warning'
)

const shouldFail = FAIL_ON_UNCOVERED_LINES && annotations.length > 0

// 4. Annotate in github
await githubUtil.annotate({
referenceCommitHash: githubUtil.getPullRequestRef(),
annotations
annotations,
conclusion: shouldFail ? 'failure' : 'success'
})
core.info('Annotation done')

if (shouldFail) {
core.setFailed(
`${annotations.length} uncovered line range(s) found in this pull request.`
)
}
} catch (error) {
if (error instanceof Error) core.setFailed(error.message)
core.info(JSON.stringify(error))
Expand Down
33 changes: 19 additions & 14 deletions src/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,10 @@ export class GithubUtil {
let checkId
for (let i = 0; i < chunks.length; i++) {
let status: 'in_progress' | 'completed' | 'queued' = 'in_progress'
let conclusion:
| 'success'
| 'action_required'
| 'cancelled'
| 'failure'
| 'neutral'
| 'skipped'
| 'stale'
| 'timed_out'
| undefined = undefined
let conclusion: CheckConclusion | undefined = undefined
if (i === chunks.length - 1) {
status = 'completed'
conclusion = 'success'
conclusion = input.conclusion ?? 'success'
}
const params = {
...github.context.repo,
Expand Down Expand Up @@ -119,7 +110,8 @@ export class GithubUtil {

buildAnnotations(
coverageFiles: CoverageFile[],
pullRequestFiles: PullRequestFiles
pullRequestFiles: PullRequestFiles,
annotationLevel: AnnotationLevel = 'warning'
): Annotations[] {
const annotations: Annotations[] = []
for (const current of coverageFiles) {
Expand All @@ -142,7 +134,7 @@ export class GithubUtil {
path: current.fileName,
start_line: uRange.start_line,
end_line: uRange.end_line,
annotation_level: 'warning',
annotation_level: annotationLevel,
message
})
}
Expand All @@ -153,18 +145,31 @@ export class GithubUtil {
}
}

export type CheckConclusion =
| 'success'
| 'action_required'
| 'cancelled'
| 'failure'
| 'neutral'
| 'skipped'
| 'stale'
| 'timed_out'

type InputAnnotateParams = {
referenceCommitHash: string
annotations: Annotations[]
conclusion?: CheckConclusion
}

export type AnnotationLevel = 'notice' | 'warning' | 'failure'

type Annotations = {
path: string
start_line: number
end_line: number
start_column?: number
end_column?: number
annotation_level: 'notice' | 'warning' | 'failure'
annotation_level: AnnotationLevel
message: string
}

Expand Down
Loading