diff --git a/.github/actions/code-pushup/action.yml b/.github/actions/code-pushup/action.yml new file mode 100644 index 000000000..82e759bf5 --- /dev/null +++ b/.github/actions/code-pushup/action.yml @@ -0,0 +1,18 @@ +name: Code PushUp +description: Minimalist GitHub Action that executes Code PushUp using local @code-pushup/ci source code + +inputs: + token: + description: GitHub token for API access + required: true + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Run Node script + run: npx tsx .github/actions/code-pushup/src/runner.ts + shell: bash + env: + TSX_TSCONFIG_PATH: .github/actions/code-pushup/tsconfig.json + GH_TOKEN: ${{ inputs.token }} diff --git a/.github/actions/code-pushup/package.json b/.github/actions/code-pushup/package.json new file mode 100644 index 000000000..b560190de --- /dev/null +++ b/.github/actions/code-pushup/package.json @@ -0,0 +1,7 @@ +{ + "name": "@code-pushup/local-action", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Minimalist GitHub Action that executes Code PushUp using local @code-pushup/ci source code for testing CI changes" +} diff --git a/.github/actions/code-pushup/project.json b/.github/actions/code-pushup/project.json new file mode 100644 index 000000000..4210dd0e5 --- /dev/null +++ b/.github/actions/code-pushup/project.json @@ -0,0 +1,8 @@ +{ + "name": "local-action", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": ".github/actions/code-pushup/src", + "projectType": "application", + "targets": {}, + "tags": ["type:app"] +} diff --git a/.github/actions/code-pushup/src/runner.ts b/.github/actions/code-pushup/src/runner.ts new file mode 100644 index 000000000..e6acc0da0 --- /dev/null +++ b/.github/actions/code-pushup/src/runner.ts @@ -0,0 +1,159 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import type { WebhookPayload } from '@actions/github/lib/interfaces'; +import type { components } from '@octokit/openapi-types'; +import { + type Comment, + type GitBranch, + type Options, + type ProviderAPIClient, + type SourceFileIssue, + runInCI, +} from '@code-pushup/ci'; +import { CODE_PUSHUP_UNICODE_LOGO } from '@code-pushup/utils'; + +type GitHubRefs = { + head: GitBranch; + base?: GitBranch; +}; + +type PullRequestPayload = NonNullable & + components['schemas']['pull-request-minimal']; + +const LOG_PREFIX = '[Code PushUp GitHub action]'; + +const MAX_COMMENT_CHARS = 65_536; + +function convertComment( + comment: Pick, +): Comment { + const { id, body = '', url } = comment; + return { id, body, url }; +} + +function isPullRequest( + payload: WebhookPayload['pull_request'], +): payload is PullRequestPayload { + return payload != null; +} + +function parseBranchRef({ ref, sha }: GitBranch): GitBranch { + return { + ref: ref.split('/').at(-1) ?? ref, + sha, + }; +} + +function parseGitRefs(): GitHubRefs { + if (isPullRequest(github.context.payload.pull_request)) { + const { head, base } = github.context.payload.pull_request; + return { head: parseBranchRef(head), base: parseBranchRef(base) }; + } + return { head: parseBranchRef(github.context) }; +} + +function createAnnotationsFromIssues(issues: SourceFileIssue[]): void { + if (issues.length > 0) { + core.info(`Creating annotations for ${issues.length} issues:`); + } + // eslint-disable-next-line functional/no-loop-statements + for (const issue of issues) { + const message = issue.message; + const properties: core.AnnotationProperties = { + title: `${CODE_PUSHUP_UNICODE_LOGO} ${issue.plugin.title} | ${issue.audit.title}`, + file: issue.source.file, + startLine: issue.source.position?.startLine, + startColumn: issue.source.position?.startColumn, + endLine: issue.source.position?.endLine, + endColumn: issue.source.position?.endColumn, + }; + switch (issue.severity) { + case 'error': + core.error(message, properties); + break; + case 'warning': + core.warning(message, properties); + break; + case 'info': + core.notice(message, properties); + break; + } + } +} + +function createGitHubApiClient(): ProviderAPIClient { + const token = process.env.GH_TOKEN; + + if (!token) { + throw new Error('No GitHub token found'); + } + + const octokit = github.getOctokit(token); + + return { + maxCommentChars: MAX_COMMENT_CHARS, + + listComments: async (): Promise => { + const comments = await octokit.paginate( + octokit.rest.issues.listComments, + { + ...github.context.repo, + issue_number: github.context.issue.number, + }, + ); + return comments.map(convertComment); + }, + + createComment: async (body: string): Promise => { + const { data } = await octokit.rest.issues.createComment({ + ...github.context.repo, + issue_number: github.context.issue.number, + body, + }); + return convertComment(data); + }, + + updateComment: async (id: number, body: string): Promise => { + const { data } = await octokit.rest.issues.updateComment({ + ...github.context.repo, + comment_id: id, + body, + }); + return convertComment(data); + }, + }; +} + +async function run(): Promise { + try { + const options: Options = { + bin: 'npx nx code-pushup --nx-bail --', + }; + + const gitRefs = parseGitRefs(); + + const apiClient = createGitHubApiClient(); + + const result = await runInCI(gitRefs, apiClient, options); + + const issues = + result.mode === 'standalone' + ? (result.newIssues ?? []) + : result.projects.flatMap(project => project.newIssues ?? []); + + if (issues.length > 0) { + core.info( + `Found ${issues.length} new issues, creating GitHub annotations`, + ); + createAnnotationsFromIssues(issues); + } + + core.info(`${LOG_PREFIX} Finished running successfully`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.error(`${LOG_PREFIX} Failed: ${message}`); + core.setFailed(message); + } +} + +await run(); diff --git a/.github/actions/code-pushup/tsconfig.json b/.github/actions/code-pushup/tsconfig.json new file mode 100644 index 000000000..88648b5f6 --- /dev/null +++ b/.github/actions/code-pushup/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/.github/workflows/code-pushup-fork.yml b/.github/workflows/code-pushup-fork.yml index 7276391e3..4e89e0fae 100644 --- a/.github/workflows/code-pushup-fork.yml +++ b/.github/workflows/code-pushup-fork.yml @@ -37,6 +37,6 @@ jobs: - name: Install dependencies run: npm ci - name: Run Code PushUp action - uses: code-pushup/github-action@v0 + uses: ./.github/actions/code-pushup with: - bin: npx nx code-pushup --nx-bail -- + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/code-pushup.yml b/.github/workflows/code-pushup.yml index 21d7bce27..9d236913e 100644 --- a/.github/workflows/code-pushup.yml +++ b/.github/workflows/code-pushup.yml @@ -39,6 +39,6 @@ jobs: - name: Install dependencies run: npm ci - name: Run Code PushUp action - uses: code-pushup/github-action@v0 + uses: ./.github/actions/code-pushup with: - bin: npx nx code-pushup --nx-bail -- + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 13e3212ae..6b81834f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "zod": "^4.0.5" }, "devDependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", "@commitlint/cli": "^19.5.0", @@ -124,6 +126,87 @@ "@rollup/rollup-win32-x64-msvc": "^4.0.0" } }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz", + "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^2.2.0", + "@octokit/core": "^5.0.1", + "@octokit/plugin-paginate-rest": "^9.2.2", + "@octokit/plugin-rest-endpoint-methods": "^10.4.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "undici": "^5.28.5" + } + }, + "node_modules/@actions/github/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@adobe/css-tools": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", @@ -3216,6 +3299,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", diff --git a/package.json b/package.json index 2d82efb62..385e84c8c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "zod": "^4.0.5" }, "devDependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1", "@beaussan/nx-knip": "^0.0.5-15", "@code-pushup/eslint-config": "^0.14.2", "@commitlint/cli": "^19.5.0",