Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
lib
.DS_Store
*.tsbuildinfo
.codegraph
54 changes: 35 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,39 @@ Note: When using `comment-type: pr_comment` or `comment-type: both` (the default
- `pull-requests: write` (or `issues: write`)
- `contents: read`

When using `add-check: true`, the job additionally requires:

- `checks: write`

### Inputs

- `paths` - [**required**] Comma separated paths of the generated jacoco xml files (supports wildcard glob pattern)
- `token` - [*optional* {default: `github.token`}] Github token to add comments to Pull Request (ensure the job has `pull-requests: write` permission)
- `min-coverage-overall` - [*optional* {default: 80%}] The minimum code coverage that is required to pass for overall project
- `min-coverage-changed-lines` - [*optional* {default: 80%}] The minimum code coverage that is required to pass for changed lines
- `update-comment` - [*optional* {default: false}] If true, updates the previous coverage report comment instead of creating new one.
- `token` - [_optional_ {default: `github.token`}] Github token to add comments to Pull Request (ensure the job has `pull-requests: write` permission)
- `min-coverage-overall` - [_optional_ {default: 80%}] The minimum code coverage that is required to pass for overall project
- `min-coverage-changed-lines` - [_optional_ {default: 80%}] The minimum code coverage that is required to pass for changed lines
- `update-comment` - [_optional_ {default: false}] If true, updates the previous coverage report comment instead of creating new one.
Requires `title` to work properly
- `comment-type` - [*optional* {pr_comment, summary, both} {default: pr_comment}] Specifies where to add the comment, whether as a PR comment,
workflow summary, or both.
- `pr-number` - [*optional*] The PR number to add the comment to. If not provided, the action will try to get it from the environment.
- `title` - [*optional*] Title for the Pull Request comment
- `head-sha` - [*optional*] The head SHA to use for comparing changes. Useful when the head SHA is not available in the context (e.g. PRs from forks via `workflow_run`).
- `base-sha` - [*optional*] The base SHA to use for comparing changes. Useful when the base SHA is not available in the context (e.g. PRs from forks via `workflow_run`).
- `skip-if-no-changes` - [*optional* {default: false}] If true, comment won't be added if there is no coverage information present for
the files changed
- `pass-emoji` - [*optional* {default: :green_apple:}] Emoji to use for pass status shown when 'coverage >= min coverage' (should be a Github supported emoji).
- `fail-emoji` - [*optional* {default: :x:}] Emoji to use for fail status shown when 'coverage < min coverage' (should be a Github supported emoji).
- `coverage-counter-type` - [*optional* {INSTRUCTION, BRANCH, LINE, COMPLEXITY, METHOD} {default: INSTRUCTION}] The type of JaCoCo counter to use for coverage calculation. Note: COMPLEXITY and METHOD are not available at per-line granularity, so changed-lines coverage will fall back to INSTRUCTION for those types.
- `show-all-modules` - [*optional* {default: false}] If true, show coverage for all modules in the comment, not just those with changed files
- `show-missing-lines` - [*optional* {default: true}] If true, show the line numbers of uncovered code in the Files table with hyperlinks to the exact lines in the source
- `continue-on-error` - [*optional* {default: true}] If true, then do not fail the action on error, but log a warning
- `debug-mode` - [*optional* {default: false}] If true, run the action in debug mode and get debug logs printed in console
- `comment-type` - [_optional_ {pr_comment, summary, both, none} {default: pr_comment}] Specifies where to add the comment, whether as a PR comment,
workflow summary, both, or none. `none` requires `add-check: true`
- `add-check` - [_optional_ {default: false}] If true, publishes the coverage report as a check run on the head commit. The check title
shows the overall coverage and, when coverage dropped, the delta (e.g. `Overall 69.09% (-1.07%)`); the check passes only when
both overall and changed-lines thresholds are met.
Requires the `checks: write` permission
- `fail-check-below-threshold` - [_optional_ {default: false}] If true, the check run is marked as failed when coverage is below the
configured minimum. Requires `add-check`
- `pr-number` - [_optional_] The PR number to add the comment to. If not provided, the action will try to get it from the environment.
- `title` - [_optional_] Title for the Pull Request comment
- `head-sha` - [_optional_] The head SHA to use for comparing changes. Useful when the head SHA is not available in the context (e.g. PRs from forks via `workflow_run`).
- `base-sha` - [_optional_] The base SHA to use for comparing changes. Useful when the base SHA is not available in the context (e.g. PRs from forks via `workflow_run`).
- `skip-if-no-changes` - [_optional_ {default: false}] If true, comment and workflow summary won't be added if there is no coverage
information present for the files changed. The check run (`add-check`) is still published
- `pass-emoji` - [_optional_ {default: :green_apple:}] Emoji to use for pass status shown when 'coverage >= min coverage' (should be a Github supported emoji).
- `fail-emoji` - [_optional_ {default: :x:}] Emoji to use for fail status shown when 'coverage < min coverage' (should be a Github supported emoji).
- `coverage-counter-type` - [_optional_ {INSTRUCTION, BRANCH, LINE, COMPLEXITY, METHOD} {default: INSTRUCTION}] The type of JaCoCo counter to use for coverage calculation. Note: COMPLEXITY and METHOD are not available at per-line granularity, so changed-lines coverage will fall back to INSTRUCTION for those types.
- `show-all-modules` - [_optional_ {default: false}] If true, show coverage for all modules in the comment, not just those with changed files
- `show-missing-lines` - [_optional_ {default: true}] If true, show the line numbers of uncovered code in the Files table with hyperlinks to the exact lines in the source
- `continue-on-error` - [_optional_ {default: true}] If true, then do not fail the action on error, but log a warning
- `debug-mode` - [_optional_ {default: false}] If true, run the action in debug mode and get debug logs printed in console

### Outputs

Expand All @@ -61,6 +71,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
Expand All @@ -83,12 +94,17 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 40
min-coverage-changed-lines: 60
add-check: true
```

<br>
<img src="/preview/single-module-screenshot.png" alt="single module screenshot" title="single module screenshot" width="700" />
<br>
<img src="/preview/multi-module-screenshot.png" alt="multi-module screenshot" title="multi-module screenshot" width="700" />
<br>
<img src="/preview/checks-summary.png" alt="checks summary screenshot" title="checks summary screenshot" width="700" />
<br>
<img src="/preview/checks-detail.png" alt="checks detail screenshot" title="checks detail screenshot" width="700" />

### Understanding the Coverage Report

Expand Down
169 changes: 169 additions & 0 deletions __tests__/action_check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {jest, describe, it, expect, beforeEach} from '@jest/globals'
import {createMockCore, createMockContext, createMockGithub} from './helpers'
import {PATCH} from './mocks.test'

const mockCore = createMockCore()
const mockContext = createMockContext()
const mockGithub = createMockGithub(mockContext)

jest.unstable_mockModule('@actions/core', () => mockCore)
jest.unstable_mockModule('@actions/github', () => mockGithub)

const action = await import('../src/action')

const HEAD_SHA = 'aahsdflais76dfa78wrglghjkaghkj'

describe('Check run publishing', function () {
let createCheck
let createComment
let inputs

function getInput(key): string {
return inputs[key] ?? ''
}

beforeEach(() => {
createCheck = jest.fn().mockResolvedValue({})
createComment = jest.fn()
inputs = {
paths: './__tests__/__fixtures__/report.xml',
token: 'SMPLEHDjasdf876a987',
title: 'JaCoCo Report',
'comment-type': 'none',
'add-check': 'true',
'fail-check-below-threshold': 'false',
'min-coverage-overall': '45',
'min-coverage-changed-lines': '80',
'pass-emoji': ':green_apple:',
'fail-emoji': ':x:',
'coverage-counter-type': 'INSTRUCTION',
'continue-on-error': 'true',
'debug-mode': 'false',
}

mockCore.getInput.mockImplementation(getInput)
mockGithub.getOctokit.mockReturnValue({
rest: {
repos: {
compareCommits: jest.fn(() => compareCommitsResponse),
},
issues: {createComment},
checks: {create: createCheck},
},
})
mockContext.eventName = 'pull_request'
mockContext.payload = {
pull_request: {
number: '45',
base: {sha: 'guasft7asdtf78asfd87as6df7y2u3'},
head: {sha: HEAD_SHA},
},
}
mockContext.repo = {owner: 'madrapps', repo: 'jacoco-playground'}
})

const compareCommitsResponse = {
data: {
files: [
{
filename: 'src/main/kotlin/com/madrapps/jacoco/Math.kt',
blob_url:
'https://github.com/thsaravana/jacoco-playground/blob/14a554976c0e5909d8e69bc8cce72958c49a7dc5/src/main/kotlin/com/madrapps/jacoco/Math.kt',
patch: PATCH.SINGLE_MODULE.MATH,
},
],
},
}

it('publishes a check run and no comment when comment-type is none', async () => {
await action.action()

expect(createComment).not.toHaveBeenCalled()
expect(mockCore.setFailed).not.toHaveBeenCalled()
expect(createCheck).toHaveBeenCalledTimes(1)
const call = createCheck.mock.calls[0][0]
expect(call).toMatchObject({
owner: 'madrapps',
repo: 'jacoco-playground',
name: 'JaCoCo Report',
head_sha: HEAD_SHA,
status: 'completed',
conclusion: 'success',
})
expect(call.output.title).toBe('Overall 35.25% (-17.21%)')
expect(call.output.summary).toEqual(CHECK_BODY)
})

it('marks the check as failed when fail-check-below-threshold is on', async () => {
inputs['fail-check-below-threshold'] = 'true'

await action.action()

expect(createCheck.mock.calls[0][0].conclusion).toBe('failure')
})

it('publishes both a comment and a check when comment-type is pr_comment', async () => {
inputs['comment-type'] = 'pr_comment'

await action.action()

expect(createComment).toHaveBeenCalledTimes(1)
expect(createCheck).toHaveBeenCalledTimes(1)
})

it('still publishes the check when skip-if-no-changes skips the comment', async () => {
inputs['comment-type'] = 'pr_comment'
inputs['skip-if-no-changes'] = 'true'
mockGithub.getOctokit.mockReturnValue({
rest: {
repos: {compareCommits: jest.fn(() => ({data: {files: []}}))},
issues: {createComment},
checks: {create: createCheck},
},
})

await action.action()

expect(createComment).not.toHaveBeenCalled()
expect(createCheck).toHaveBeenCalledTimes(1)
const call = createCheck.mock.calls[0][0]
expect(call.output.title).toBe('Overall 35.25%')
expect(call.output.summary).toContain(
'There is no coverage information present for the changed lines'
)
})

it('does not log the token in debug mode', async () => {
inputs['debug-mode'] = 'true'

await action.action()

const logged = mockCore.info.mock.calls.map(call => String(call[0]))
expect(logged.some(line => line.includes(inputs.token))).toBe(false)
})

it('fails the action on 403 even with continue-on-error', async () => {
createCheck.mockRejectedValue(
Object.assign(new Error('Resource not accessible'), {status: 403})
)

await action.action()

expect(mockCore.setFailed).toHaveBeenCalledTimes(1)
expect(mockCore.setFailed.mock.calls[0][0].message).toContain(
'checks: write'
)
expect(mockCore.error).not.toHaveBeenCalled()
})
})

const CHECK_BODY = `|Overall Project|35.25% **\`-17.21%\`**|:x:|
|:-|:-|:-:|
|Changed lines|32.26%|:x:|
<br>

|File|Coverage||
|:-|:-|:-:|
|[Math.kt](https://github.com/thsaravana/jacoco-playground/blob/14a554976c0e5909d8e69bc8cce72958c49a7dc5/src/main/kotlin/com/madrapps/jacoco/Math.kt)|42% **\`-42%\`**|:x:|`
110 changes: 110 additions & 0 deletions __tests__/inputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {jest, describe, it, expect, beforeEach} from '@jest/globals'
import {createMockCore} from './helpers'

const mockCore = createMockCore()
jest.unstable_mockModule('@actions/core', () => mockCore)

const {parseInputs} = await import('../src/inputs')

const BASE_INPUTS: Record<string, string> = {
paths: './report.xml',
token: 'token',
'min-coverage-overall': '80',
'min-coverage-changed-lines': '70',
'comment-type': 'pr_comment',
'coverage-counter-type': 'INSTRUCTION',
'continue-on-error': 'true',
'add-check': 'false',
'fail-check-below-threshold': 'false',
}

function withInputs(overrides: Record<string, string>): void {
const inputs = {...BASE_INPUTS, ...overrides}
mockCore.getInput.mockImplementation(key => inputs[key] ?? '')
}

describe('parseInputs', function () {
beforeEach(() => {
mockCore.setFailed.mockReset()
})

it('parses valid inputs', function () {
withInputs({'add-check': 'true', 'fail-check-below-threshold': 'true'})
const inputs = parseInputs()
expect(mockCore.setFailed).not.toHaveBeenCalled()
expect(inputs).toMatchObject({
reportPaths: ['./report.xml'],
token: 'token',
minCoverage: {overall: 80, changed: 70},
commentType: 'pr_comment',
coverageCounterType: 'INSTRUCTION',
addCheck: true,
failCheckBelowThreshold: true,
})
})

it('accepts comment-type none when add-check is true', function () {
withInputs({'comment-type': 'none', 'add-check': 'true'})
const inputs = parseInputs()
expect(mockCore.setFailed).not.toHaveBeenCalled()
expect(inputs.commentType).toBe('none')
})

it('fails when comment-type is none and add-check is false', function () {
withInputs({'comment-type': 'none'})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith(
expect.stringContaining('nothing to publish')
)
})

it('fails when fail-check-below-threshold is set without add-check', function () {
withInputs({'fail-check-below-threshold': 'true'})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith(
expect.stringContaining(
"'fail-check-below-threshold' requires 'add-check'"
)
)
})

it('fails on invalid comment-type', function () {
withInputs({'comment-type': 'nowhere'})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith(
expect.stringContaining("'comment-type' nowhere is invalid")
)
})

it('fails when token is missing', function () {
withInputs({token: ''})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith("'token' is missing")
})

it('fails when paths is missing', function () {
withInputs({paths: ''})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith("'paths' is missing")
})

it('fails on removed min-coverage-changed-files input', function () {
withInputs({'min-coverage-changed-files': '50'})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith(
expect.stringContaining(
"'min-coverage-changed-files' is no longer supported"
)
)
})

it('fails on invalid coverage-counter-type', function () {
withInputs({'coverage-counter-type': 'bogus'})
expect(parseInputs()).toBeUndefined()
expect(mockCore.setFailed).toHaveBeenCalledWith(
expect.stringContaining("'coverage-counter-type' BOGUS is invalid")
)
})
})
Loading