├── .gitignore ├── .gitattributes ├── .editorconfig ├── .github └── workflows │ └── test.yml ├── action.yml ├── package.json ├── LICENSE ├── test └── json-report.json ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/index.js -crlf 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | insert_final_newline = false -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "test-local" 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-node@v4 9 | with: 10 | node-version: 20 11 | - run: npm ci 12 | - name: Testing / Linting 13 | run: npm t 14 | - name: Package 15 | run: npm run package 16 | - name: "Check that packaging didn't create any changes" 17 | run: '[ -z "$(git status --porcelain)" ];' 18 | - name: Annotate 19 | if: github.event_name == 'push' 20 | uses: ./ 21 | with: 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | input: ./test/json-report.json 24 | title: 'lint' 25 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Annotate Action' 2 | branding: 3 | icon: 'edit-3' 4 | color: 'yellow' 5 | author: 'Guillaume Grossetie' 6 | description: 'Creates annotations from a JSON file' 7 | inputs: 8 | repo-token: 9 | description: 'Token used to interact with the GitHub API.' 10 | required: true 11 | input: 12 | description: 'Path to a JSON file which contains a list of annotations.' 13 | required: true 14 | title: 15 | description: 'Title of the check' 16 | required: false 17 | default: 'check' 18 | ignore-unauthorized-error: 19 | description: 'Ignore errors when the provided repo-token does not have write permissions' 20 | required: false 21 | default: 'false' 22 | ignore-missing-file: 23 | description: 'Ignore if the file which contains annotations is missing' 24 | required: false 25 | default: 'true' 26 | runs: 27 | using: 'node20' 28 | main: 'dist/index.js' 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yuzutech/annotations-action", 3 | "version": "0.5.0", 4 | "description": "GitHub action that creates annotations from a JSON file", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "standard index.js", 8 | "lint-fix": "standard --fix index.js", 9 | "package": "ncc build index.js -o dist", 10 | "test": "npm run lint" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/yuzutech/annotations-action.git" 15 | }, 16 | "keywords": [ 17 | "GitHub", 18 | "Actions", 19 | "JavaScript", 20 | "Annotations" 21 | ], 22 | "author": "GitHub", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/yuzutech/annotations-action/issues" 26 | }, 27 | "homepage": "https://github.com/yuzutech/annotations-action#readme", 28 | "dependencies": { 29 | "@actions/core": "1.6.0", 30 | "@actions/github": "5.0.0" 31 | }, 32 | "devDependencies": { 33 | "@vercel/ncc": "0.38.1", 34 | "eslint": "8.5.0", 35 | "standard": "^16.0.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yuzu tech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/json-report.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "file": "index.js", 4 | "line": 33, 5 | "title": "Doing foo is not recommended, consider using bar", 6 | "annotation_level": "warning", 7 | "message": "`foo` is deprecated since version 1.2.3, please use `bar`." 8 | }, 9 | { 10 | "file": "index.js", 11 | "line": 11, 12 | "title": "Quick tip, avoid drinking too much beer", 13 | "annotation_level": "notice", 14 | "message": "Beer is one of the oldest and most widely consumed alcoholic drinks in the world, but drink in moderation!" 15 | }, 16 | { 17 | "file": "index.js", 18 | "line": 42, 19 | "title": "ArithmeticException", 20 | "annotation_level": "failure", 21 | "message": "Exception in thread \"main\" java.lang.ArithmeticException: / by zero\n at Test.bar(Test.java:9)\n at Test.foo(Test.java:3)\n at Main.main(Main.java:3)" 22 | }, 23 | { 24 | "path": "index.js", 25 | "start_line": 42, 26 | "end_line": 43, 27 | "annotation_level": "invalid", 28 | "message": "Using GitHub's official format allows for some cool stuff (e.g. multiline annotations)." 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Annotations Action 2 | 3 | [![GitHub Action badge](https://github.com/yuzutech/annotations-action/workflows/test-local/badge.svg)](https://github.com/yuzutech/annotations-action/actions?query=workflow%3Atest-local) 4 | 5 | This action creates annotations from a JSON file. 6 | 7 | In order to use this action, you will need to generate a JSON file using the following format (all options from https://docs.github.com/en/free-pro-team@latest/rest/reference/checks#annotations-items are also supported and it's recommended to use them): 8 | 9 | ```js 10 | [ 11 | { 12 | file: "path/to/file.js", 13 | line: 5, 14 | title: "title for my annotation", 15 | message: "my message", 16 | annotation_level: "failure" 17 | } 18 | ] 19 | ``` 20 | 21 | ## Permissions 22 | 23 | It is possible you have a `GITHUB_TOKEN` with restricted access by default, in which case you might get the following error: 24 | 25 | ``` 26 | Error: GitHubApiUnauthorizedError: Unable to create a check, please make sure that the provided 'repo-token' has write permissions to 'your/repo' - cause: HttpError: Resource not accessible by integration 27 | ``` 28 | 29 | You need to provide the `checks` write permission: 30 | 31 | ```yaml 32 | permissions: 33 | checks: write 34 | ``` 35 | 36 | You can use `permissions` either as a top-level key, to apply to all jobs in the workflow, or within specific jobs. 37 | When you add the permissions key within a specific job, all actions and run commands within that job that use the `GITHUB_TOKEN` gain the access rights you specify. 38 | 39 | **IMPORTANT**: Setting a `permissions` field will disable all omitted permissions, like the `contents: read`, which is needed to clone and read your repository. 40 | 41 | You can learn more about `GITHUB_TOKEN` permissions at: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token 42 | 43 | ## Inputs 44 | 45 | ### `repo-token` 46 | 47 | **Required** Token used to interact with the GitHub API. 48 | 49 | ### `input` 50 | 51 | **Required** Path to a JSON file which contains a list of annotations. 52 | 53 | ### `title` 54 | 55 | **Optional** Title of the check. Default: "check". 56 | 57 | ### `ignore-unauthorized-error` 58 | 59 | **Optional** Ignore errors when the provided repo-token does not have write permissions. Default: "false". 60 | 61 | ### `ignore-missing-file` 62 | 63 | **Optional** Ignore if the file which contains annotations is missing. Default: "true". 64 | 65 | ## Example usage 66 | 67 | ```yml 68 | - name: Annotate 69 | uses: yuzutech/annotations-action@v0.4.0 70 | with: 71 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 72 | title: 'lint' 73 | input: './annotations.json' 74 | ``` 75 | 76 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { getInput, info, setFailed } from '@actions/core' 2 | import { context, getOctokit } from '@actions/github' 3 | import { promises as fs } from 'fs' 4 | 5 | const ANNOTATION_LEVELS = ['notice', 'warning', 'failure'] 6 | 7 | class GitHubApiUnauthorizedError extends Error { 8 | constructor (message) { 9 | super(message) 10 | this.name = 'GitHubApiUnauthorizedError' 11 | } 12 | } 13 | 14 | class GitHubApiError extends Error { 15 | constructor (message) { 16 | super(message) 17 | this.name = 'GitHubApiError' 18 | } 19 | } 20 | 21 | const batch = (size, inputs) => inputs.reduce((batches, input) => { 22 | const current = batches[batches.length - 1] 23 | 24 | current.push(input) 25 | 26 | if (current.length === size) { 27 | batches.push([]) 28 | } 29 | 30 | return batches 31 | }, [[]]) 32 | 33 | const createCheck = async function (octokit, owner, repo, title, ref) { 34 | info(`Creating check {owner: '${owner}', repo: '${repo}', name: ${title}}`) 35 | try { 36 | const { data: { id: checkRunId } } = await octokit.rest.checks.create({ 37 | owner, 38 | repo, 39 | name: title, 40 | head_sha: ref, 41 | status: 'in_progress' 42 | }) 43 | return checkRunId 44 | } catch (err) { 45 | if (err.message === 'Resource not accessible by integration') { 46 | throw new GitHubApiUnauthorizedError(`Unable to create a check, please make sure that the provided 'repo-token' has write permissions to '${owner}/${repo}' - cause: ${err}`) 47 | } 48 | throw new GitHubApiError(`Unable to create a check to '${owner}/${repo}' - cause: ${err}`) 49 | } 50 | } 51 | 52 | const updateCheck = async function (octokit, owner, repo, checkRunId, conclusion, title, summary, annotations) { 53 | info(`Updating check {owner: '${owner}', repo: '${repo}', check_run_id: ${checkRunId}}`) 54 | try { 55 | await octokit.rest.checks.update({ 56 | owner, 57 | repo, 58 | check_run_id: checkRunId, 59 | status: 'completed', 60 | conclusion, 61 | output: { 62 | title, 63 | summary, 64 | annotations 65 | } 66 | }) 67 | } catch (err) { 68 | throw new GitHubApiError(`Unable to update check {owner: '${owner}', repo: '${repo}', check_run_id: ${checkRunId}} - cause: ${err}`) 69 | } 70 | } 71 | 72 | const stats = function (annotations) { 73 | const annotationsPerLevel = annotations.reduce((acc, annotation) => { 74 | const level = annotation.annotation_level 75 | let annotations 76 | if (level in acc) { 77 | annotations = acc[level] 78 | } else { 79 | annotations = [] 80 | acc[level] = annotations 81 | } 82 | annotations.push(annotation) 83 | return acc 84 | }, {}) 85 | const failureCount = (annotationsPerLevel.failure || []).length || 0 86 | const warningCount = (annotationsPerLevel.warning || []).length || 0 87 | const noticeCount = (annotationsPerLevel.notice || []).length || 0 88 | return { failureCount, warningCount, noticeCount } 89 | } 90 | 91 | const generateSummary = function (failureCount, warningCount, noticeCount) { 92 | const messages = [] 93 | if (failureCount > 0) { 94 | messages.push(`${failureCount} failure(s) found`) 95 | } 96 | if (warningCount > 0) { 97 | messages.push(`${warningCount} warning(s) found`) 98 | } 99 | if (noticeCount > 0) { 100 | messages.push(`${noticeCount} notice(s) found`) 101 | } 102 | return messages.join('\n') 103 | } 104 | 105 | const generateConclusion = function (failureCount, warningCount, noticeCount) { 106 | let conclusion = 'success' 107 | if (failureCount > 0) { 108 | conclusion = 'failure' 109 | } else if (warningCount > 0 || noticeCount > 0) { 110 | conclusion = 'neutral' 111 | } 112 | return conclusion 113 | } 114 | 115 | const booleanValue = function (input) { 116 | return /^\s*(true|1)\s*$/i.test(input) 117 | } 118 | 119 | const readAnnotationsFile = async function (inputPath) { 120 | const ignoreMissingFileValue = getInput('ignore-missing-file', { required: false }) || 'true' 121 | const ignoreMissingFile = booleanValue(ignoreMissingFileValue) 122 | try { 123 | const inputContent = await fs.readFile(inputPath, { encoding: 'utf8' }) 124 | return JSON.parse(inputContent) 125 | } catch (err) { 126 | if (err.code === 'ENOENT' && ignoreMissingFile) { 127 | info(`Ignoring missing file at '${inputPath}' because 'ignore-missing-file' is true`) 128 | return null 129 | } else { 130 | throw err 131 | } 132 | } 133 | } 134 | 135 | async function run () { 136 | try { 137 | const repoToken = getInput('repo-token', { required: true }) 138 | const inputPath = getInput('input', { required: true }) 139 | const title = getInput('title', { required: false }) 140 | 141 | const octokit = getOctokit(repoToken) 142 | const pullRequest = context.payload.pull_request 143 | let ref 144 | if (pullRequest) { 145 | ref = pullRequest.head.sha 146 | } else { 147 | ref = context.sha 148 | } 149 | const owner = context.repo.owner 150 | const repo = context.repo.repo 151 | 152 | const annotations = await readAnnotationsFile(inputPath) 153 | if (annotations === null) { 154 | return 155 | } 156 | const checkRunId = await createCheck(octokit, owner, repo, title, ref) 157 | const { failureCount, warningCount, noticeCount } = stats(annotations) 158 | info(`Found ${failureCount} failure(s), ${warningCount} warning(s) and ${noticeCount} notice(s)`) 159 | const summary = generateSummary(failureCount, warningCount, noticeCount) 160 | const conclusion = generateConclusion(failureCount, warningCount, noticeCount) 161 | 162 | // The GitHub API requires that annotations are submitted in batches of 50 elements maximum 163 | const batchedAnnotations = batch(50, annotations) 164 | for (const batch of batchedAnnotations) { 165 | const annotations = batch.map(annotation => { 166 | let annotationLevel 167 | if (ANNOTATION_LEVELS.includes(annotation.annotation_level)) { 168 | annotationLevel = annotation.annotation_level 169 | } else { 170 | annotationLevel = 'failure' 171 | } 172 | return { 173 | path: annotation.file, 174 | start_line: annotation.line, 175 | end_line: annotation.line, 176 | ...annotation, 177 | annotation_level: annotationLevel 178 | } 179 | }) 180 | await updateCheck(octokit, owner, repo, checkRunId, conclusion, title, summary, annotations) 181 | } 182 | } catch (error) { 183 | const ignoreUnauthorizedErrorValue = getInput('ignore-unauthorized-error', { required: false }) || 'false' 184 | const ignoreUnauthorizedError = booleanValue(ignoreUnauthorizedErrorValue) 185 | if (error.name === 'GitHubApiUnauthorizedError' && ignoreUnauthorizedError) { 186 | info(`Ignoring the following unauthorized error because 'ignore-unauthorized-error' is true: ${error}`) 187 | return 188 | } 189 | setFailed(error) 190 | } 191 | } 192 | 193 | run() 194 | --------------------------------------------------------------------------------