From 0234cefe3440a447efe3afe3c21c3deb4de9a7b6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:53:14 -0400 Subject: [PATCH] feat: add artifact_comment action for PR artifacts Introduce a new composite action that builds and posts a sticky pull request comment with links to selected workflow artifacts from a source run. It supports wildcard artifact filtering, run/PR resolution (including workflow_run fallbacks), dry-run mode, and structured outputs. The change also adds full unit test coverage for parsing, filtering, PR resolution, comment formatting, and failure paths, updates the action catalog in README, adds CI matrix coverage for the new action, and grants CI the `actions: read` permission required by this workflow. --- .github/workflows/ci.yml | 1 + README.md | 1 + actions/artifact_comment/README.md | 85 ++++ actions/artifact_comment/action.yml | 89 ++++ actions/artifact_comment/artifact_comment.js | 263 +++++++++++ actions/artifact_comment/ci-matrix.json | 10 + .../artifact_comment/artifact_comment.test.js | 407 ++++++++++++++++++ 7 files changed, 856 insertions(+) create mode 100644 actions/artifact_comment/README.md create mode 100644 actions/artifact_comment/action.yml create mode 100644 actions/artifact_comment/artifact_comment.js create mode 100644 actions/artifact_comment/ci-matrix.json create mode 100644 tests/artifact_comment/artifact_comment.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc78f95..c5c964e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: name: ${{ format('{0} ({1}, {2})', matrix.action, matrix.runs-on, toJson(matrix.with)) }} needs: generate-matrix permissions: + actions: read # needed for artifact_comment action contents: write # needed for setup_release action runs-on: ${{ matrix.runs-on }} container: ${{ matrix.container }} diff --git a/README.md b/README.md index 967ced4..6539350 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ | Action | Description | Type | Language | |-------------------------------------------------------|---------------------------------------------------------------------------------|-----------|------------------| +| [artifact_comment](actions/artifact_comment#readme) | Post workflow artifact links in a sticky pull request comment | composite | javascript | | [audit_repos](actions/audit_repos#readme) | Audit repositories in an organization | composite | javascript | | [facebook_post](actions/facebook_post#readme) | Post to Facebook page/group using Graph API | docker | python | | [get_changed_files](actions/get_changed_files#readme) | Get the list of changed files in a pull request | composite | javascript | diff --git a/actions/artifact_comment/README.md b/actions/artifact_comment/README.md new file mode 100644 index 0000000..049b3cc --- /dev/null +++ b/actions/artifact_comment/README.md @@ -0,0 +1,85 @@ +# artifact_comment + +Post links to selected workflow artifacts in a pull request comment. The action uses a stable message ID, so later +workflow runs update the existing comment instead of adding another comment. + +## 🛠️ Prep Work + +Run this action from a `workflow_run` workflow after the CI workflow that uploads the artifacts has completed. This +gives the follow-up workflow its own trusted token, including when the source pull request came from a fork. The token +needs permission to read Actions artifacts and write pull request comments: + +```yaml +permissions: + actions: read + pull-requests: write +``` + +## 🚀 Basic Usage + +See [action.yml](action.yml) + +```yaml +name: Comment PR Artifacts + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: {} + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + permissions: + actions: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Comment with build artifacts + uses: LizardByte/actions/actions/artifact_comment@master + with: + artifact_patterns: | + build-linux-* + build-windows-* +``` + +Each non-empty line in `artifact_patterns` is matched against the complete artifact name. `*` matches any number of +characters and `?` matches one character. A literal artifact name is therefore also a valid pattern. + +## 📥 Inputs + +| Name | Description | Default | Required | +|---------------------|------------------------------------------------------------------------------|---------------------------------------|----------| +| `artifact_patterns` | Newline-separated artifact name patterns. Supports `*` and `?` wildcards. | `*` | `false` | +| `dry_run` | Build the comment and outputs without posting it. | `false` | `false` | +| `message_id` | Stable ID used to find and update the existing comment. | `artifact-comment` | `false` | +| `pr_number` | Pull request number. By default it is resolved from the source workflow run. | | `false` | +| `run_id` | Workflow run ID whose artifacts should be listed. | Source workflow run, then current run | `false` | +| `title` | Markdown heading text for the comment. | `Build artifacts` | `false` | +| `token` | GitHub token used to read artifacts and post the pull request comment. | `${{ github.token }}` | `false` | + +## 📤 Outputs + +| Name | Description | +|-------------------|---------------------------------------------------------------| +| `artifact_count` | Number of artifacts included in the comment. | +| `artifact_names` | Newline-separated names of artifacts included in the comment. | +| `comment_created` | Whether a new pull request comment was created. | +| `comment_body` | Generated Markdown comment body. | +| `comment_id` | ID of the created or updated pull request comment. | +| `comment_updated` | Whether an existing pull request comment was updated. | +| `pr_number` | Pull request number resolved for the source workflow run. | + +## 📝 Notes + +- The default `message_id` creates one sticky artifact comment per pull request. Set a different ID for each workflow + if multiple workflows should maintain separate artifact comments. +- When no artifact matches, the action updates the comment to say that the source workflow run produced no matching + artifacts. This prevents links from an older run from remaining visible. +- Artifact download links require the reader to sign in to GitHub and stop working when GitHub expires or deletes the + artifact. +- The action first uses the pull request attached to the source workflow run. If GitHub omits that association, it + resolves the open pull request from the source repository, branch, and head SHA. diff --git a/actions/artifact_comment/action.yml b/actions/artifact_comment/action.yml new file mode 100644 index 0000000..0030997 --- /dev/null +++ b/actions/artifact_comment/action.yml @@ -0,0 +1,89 @@ +--- +name: "PR Artifact Comment" +description: "Post workflow artifact links in a sticky pull request comment." +author: "LizardByte" + +branding: + icon: package + color: green + +inputs: + artifact_patterns: + description: | + Newline-separated artifact name patterns to include. Supports * and ? wildcards. + required: false + default: '*' + dry_run: + description: 'Build the comment without posting it.' + required: false + default: 'false' + message_id: + description: 'Stable ID used to update the existing comment on later workflow runs.' + required: false + default: 'artifact-comment' + pr_number: + description: 'Pull request number to comment on. By default it is resolved from the source workflow run.' + required: false + default: '' + run_id: + description: 'Workflow run ID whose artifacts should be listed.' + required: false + default: ${{ github.event.workflow_run.id || github.run_id }} + title: + description: 'Markdown heading text for the artifact comment.' + required: false + default: 'Build artifacts' + token: + description: 'GitHub token used to read artifacts and post the pull request comment.' + required: false + default: ${{ github.token }} + +outputs: + artifact_count: + description: 'Number of artifacts included in the comment.' + value: ${{ steps.build_comment.outputs.ARTIFACT_COUNT }} + artifact_names: + description: 'Newline-separated names of artifacts included in the comment.' + value: ${{ steps.build_comment.outputs.ARTIFACT_NAMES }} + comment_created: + description: 'Whether a new pull request comment was created.' + value: ${{ steps.comment.outputs.comment-created }} + comment_body: + description: 'Generated Markdown comment body.' + value: ${{ steps.build_comment.outputs.COMMENT_BODY }} + comment_id: + description: 'ID of the created or updated pull request comment.' + value: ${{ steps.comment.outputs.comment-id }} + comment_updated: + description: 'Whether an existing pull request comment was updated.' + value: ${{ steps.comment.outputs.comment-updated }} + pr_number: + description: 'Pull request number resolved for the source workflow run.' + value: ${{ steps.build_comment.outputs.PR_NUMBER }} + +runs: + using: "composite" + steps: + - name: Build artifact comment + id: build_comment + env: + INPUT_ARTIFACT_PATTERNS: ${{ inputs.artifact_patterns }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_RUN_ID: ${{ inputs.run_id }} + INPUT_TITLE: ${{ inputs.title }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ inputs.token }} + script: | + const script = require('${{ github.action_path }}/artifact_comment.js'); + await script({ github, context, core }); + + - name: Add pull request comment + id: comment + if: inputs.dry_run != 'true' + uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3.12.0 + with: + issue: ${{ steps.build_comment.outputs.PR_NUMBER }} + message: ${{ steps.build_comment.outputs.COMMENT_BODY }} + message-id: ${{ inputs.message_id }} + repo-token: ${{ inputs.token }} diff --git a/actions/artifact_comment/artifact_comment.js b/actions/artifact_comment/artifact_comment.js new file mode 100644 index 0000000..5b1311b --- /dev/null +++ b/actions/artifact_comment/artifact_comment.js @@ -0,0 +1,263 @@ +/** + * Build a pull request comment containing links to selected workflow artifacts. + */ + +/** + * Parse newline-separated artifact name patterns. + * @param {string} value - Raw pattern input. + * @returns {string[]} Artifact name patterns. + */ +function parsePatterns(value) { + const patterns = value + .split(/\r?\n/) + .map(pattern => pattern.trim()) + .filter(Boolean); + + return patterns.length > 0 ? patterns : ['*']; +} + +/** + * Convert a simple artifact-name glob to a regular expression. + * @param {string} pattern - Glob pattern supporting * and ? wildcards. + * @returns {RegExp} Anchored regular expression. + */ +function globToRegExp(pattern) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, String.raw`\$&`) + .replaceAll('*', '.*') + .replaceAll('?', '.'); + + return new RegExp(`^${escaped}$`); +} + +/** + * Filter and sort artifacts by their names. + * @param {Object[]} artifacts - Workflow artifact objects. + * @param {string[]} patterns - Artifact name glob patterns. + * @returns {Object[]} Matching artifacts sorted by name. + */ +function selectArtifacts(artifacts, patterns) { + const matchers = patterns.map(globToRegExp); + + return artifacts + .filter(artifact => matchers.some(matcher => matcher.test(artifact.name))) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Escape characters with special meaning in a Markdown link label. + * @param {string} value - Link label. + * @returns {string} Escaped link label. + */ +function escapeLinkLabel(value) { + return value.replace(/([\\[\]])/g, String.raw`\$1`); +} + +/** + * Normalize and validate an optional pull request number. + * @param {number|string|null|undefined} value - Pull request number value. + * @returns {string} Normalized pull request number, or an empty string. + */ +function normalizePrNumber(value) { + const prNumber = String(value ?? ''); + if (prNumber === '') { + return ''; + } + + if (!/^\d+$/.test(prNumber)) { + throw new Error(`Invalid PR number value: ${prNumber}`); + } + + return prNumber; +} + +/** + * Resolve the pull request associated with a source workflow run. + * @param {Object} github - GitHub API object. + * @param {Object} context - GitHub Actions context object. + * @param {Object} sourceRun - Source workflow run. + * @param {number|string|null|undefined} providedPrNumber - Optional explicit PR number. + * @returns {Promise} Pull request number. + */ +async function resolvePrNumber(github, context, sourceRun, providedPrNumber) { + let prNumber = normalizePrNumber(providedPrNumber); + if (prNumber) { + return prNumber; + } + + const eventName = sourceRun.event || ''; + if (eventName !== 'pull_request') { + throw new Error(`Source workflow run event is "${eventName || 'unknown'}", not "pull_request".`); + } + + prNumber = normalizePrNumber(sourceRun.pull_requests?.[0]?.number); + if (prNumber) { + return prNumber; + } + + const headBranch = sourceRun.head_branch || ''; + const headRepository = sourceRun.head_repository || {}; + const headRepositoryName = headRepository.full_name || ''; + const headOwner = headRepository.owner?.login + || headRepository.owner?.name + || headRepositoryName.split('/')[0] + || ''; + const head = headOwner && headBranch ? `${headOwner}:${headBranch}` : ''; + + if (head) { + console.log(`workflow_run.pull_requests is empty; resolving PR from head ${head}.`); + const pullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head, + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + const baseRepository = `${context.repo.owner}/${context.repo.repo}`; + const matchingSha = pullRequests.find(pullRequest => pullRequest.head?.sha === sourceRun.head_sha); + const matchingBase = pullRequests.find( + pullRequest => pullRequest.base?.repo?.full_name === baseRepository + ); + const pullRequest = matchingSha || matchingBase || pullRequests[0]; + prNumber = normalizePrNumber(pullRequest?.number); + } + + if (!prNumber) { + throw new Error([ + 'Unable to determine PR number for pull_request workflow run.', + `head_repository=${headRepositoryName || ''}`, + `head_branch=${headBranch || ''}`, + `head_sha=${sourceRun.head_sha || ''}`, + `payload_pull_requests=${sourceRun.pull_requests?.length || 0}`, + ].join(' ')); + } + + return prNumber; +} + +/** + * Build the Markdown body for the pull request comment. + * @param {Object} params - Comment parameters. + * @param {Object[]} params.artifacts - Selected workflow artifacts. + * @param {string} params.owner - Repository owner. + * @param {string} params.repo - Repository name. + * @param {string} params.runId - Workflow run ID. + * @param {string} params.serverUrl - GitHub server URL. + * @param {string} params.title - Comment heading. + * @returns {string} Markdown comment body. + */ +function formatComment({ artifacts, owner, repo, runId, serverUrl, title }) { + const baseUrl = serverUrl.replace(/\/$/, ''); + const runUrl = `${baseUrl}/${owner}/${repo}/actions/runs/${runId}`; + const lines = [ + `## ${title}`, + '', + ]; + + if (artifacts.length === 0) { + lines.push(`No artifacts matched the configured patterns in [workflow run ${runId}](${runUrl}).`); + return lines.join('\n'); + } + + lines.push(`Artifacts from [workflow run ${runId}](${runUrl}):`, ''); + + for (const artifact of artifacts) { + const artifactUrl = `${runUrl}/artifacts/${artifact.id}`; + lines.push(`- [${escapeLinkLabel(artifact.name)}](${artifactUrl})`); + } + + lines.push('', 'You must be signed in to GitHub to download workflow artifacts.'); + return lines.join('\n'); +} + +/** + * List all artifacts for a workflow run. + * @param {Object} github - GitHub API object. + * @param {Object} context - GitHub Actions context object. + * @param {number} runId - Workflow run ID. + * @returns {Promise} Workflow artifacts. + */ +async function listArtifacts(github, context, runId) { + const options = github.rest.actions.listWorkflowRunArtifacts.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + per_page: 100, + }); + + return github.paginate(options); +} + +/** + * Get a workflow run by ID. + * @param {Object} github - GitHub API object. + * @param {Object} context - GitHub Actions context object. + * @param {number} runId - Workflow run ID. + * @returns {Promise} Workflow run. + */ +async function getWorkflowRun(github, context, runId) { + const { data } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + + return data; +} + +/** + * Build the artifact comment and expose it to the composite action. + * @param {Object} params - Function parameters. + * @param {Object} params.github - GitHub API object. + * @param {Object} params.context - GitHub Actions context object. + * @param {Object} params.core - GitHub Actions core object. + */ +async function artifactCommentAction({ github, context, core }) { + try { + const rawRunId = process.env.INPUT_RUN_ID || String(context.runId); + if (!/^[1-9]\d*$/.test(rawRunId)) { + throw new Error(`Invalid workflow run ID: "${rawRunId}".`); + } + + const runId = Number(rawRunId); + const sourceRun = await getWorkflowRun(github, context, runId); + const prNumber = await resolvePrNumber( + github, + context, + sourceRun, + process.env.INPUT_PR_NUMBER + ); + const patterns = parsePatterns(process.env.INPUT_ARTIFACT_PATTERNS || '*'); + const artifacts = await listArtifacts(github, context, runId); + const selectedArtifacts = selectArtifacts(artifacts, patterns); + const commentBody = formatComment({ + artifacts: selectedArtifacts, + owner: context.repo.owner, + repo: context.repo.repo, + runId: rawRunId, + serverUrl: context.serverUrl || 'https://github.com', + title: process.env.INPUT_TITLE || 'Build artifacts', + }); + + console.log(`Found ${artifacts.length} artifact(s); selected ${selectedArtifacts.length}.`); + core.setOutput('ARTIFACT_COUNT', String(selectedArtifacts.length)); + core.setOutput('ARTIFACT_NAMES', selectedArtifacts.map(artifact => artifact.name).join('\n')); + core.setOutput('COMMENT_BODY', commentBody); + core.setOutput('PR_NUMBER', prNumber); + } catch (error) { + core.setFailed(`Failed to build artifact comment: ${error.message}`); + } +} + +module.exports = artifactCommentAction; +module.exports.escapeLinkLabel = escapeLinkLabel; +module.exports.formatComment = formatComment; +module.exports.getWorkflowRun = getWorkflowRun; +module.exports.globToRegExp = globToRegExp; +module.exports.listArtifacts = listArtifacts; +module.exports.normalizePrNumber = normalizePrNumber; +module.exports.parsePatterns = parsePatterns; +module.exports.resolvePrNumber = resolvePrNumber; +module.exports.selectArtifacts = selectArtifacts; diff --git a/actions/artifact_comment/ci-matrix.json b/actions/artifact_comment/ci-matrix.json new file mode 100644 index 0000000..5893d20 --- /dev/null +++ b/actions/artifact_comment/ci-matrix.json @@ -0,0 +1,10 @@ +[ + { + "runs-on": "ubuntu-latest", + "with": { + "artifact_patterns": "artifact-comment-ci-*", + "dry_run": "true", + "pr_number": "1" + } + } +] diff --git a/tests/artifact_comment/artifact_comment.test.js b/tests/artifact_comment/artifact_comment.test.js new file mode 100644 index 0000000..0355a49 --- /dev/null +++ b/tests/artifact_comment/artifact_comment.test.js @@ -0,0 +1,407 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +const { setupConsoleMocks } = require('../testUtils.js'); + +const artifactCommentAction = require('../../actions/artifact_comment/artifact_comment.js'); +const { + escapeLinkLabel, + formatComment, + getWorkflowRun, + globToRegExp, + listArtifacts, + normalizePrNumber, + parsePatterns, + resolvePrNumber, + selectArtifacts, +} = artifactCommentAction; + +function createMockContext(overrides = {}) { + return { + repo: { + owner: 'test-org', + repo: 'test-repo', + }, + runId: 123, + serverUrl: 'https://github.com', + ...overrides, + }; +} + +function createMockGithub() { + return { + rest: { + actions: { + getWorkflowRun: jest.fn().mockResolvedValue({ data: createSourceRun() }), + listWorkflowRunArtifacts: { + endpoint: { + merge: jest.fn().mockReturnValue({ endpoint: 'artifacts' }), + }, + }, + }, + pulls: { + list: jest.fn(), + }, + }, + paginate: jest.fn(), + }; +} + +function createSourceRun(overrides = {}) { + return { + event: 'pull_request', + head_branch: 'feature', + head_repository: { + full_name: 'contributor/test-repo', + owner: { + login: 'contributor', + }, + }, + head_sha: 'head-sha', + pull_requests: [{ number: 42 }], + ...overrides, + }; +} + +function createMockCore() { + return { + setFailed: jest.fn(), + setOutput: jest.fn(), + }; +} + +let consoleMocks; + +beforeEach(() => { + jest.clearAllMocks(); + consoleMocks = setupConsoleMocks(); + delete process.env.INPUT_ARTIFACT_PATTERNS; + delete process.env.INPUT_PR_NUMBER; + delete process.env.INPUT_RUN_ID; + delete process.env.INPUT_TITLE; +}); + +afterEach(() => { + consoleMocks.restore(); +}); + +describe('parsePatterns', () => { + test('trims patterns and ignores empty lines', () => { + expect(parsePatterns(' build-* \r\n\r\n exact-name\n')).toEqual(['build-*', 'exact-name']); + }); + + test('defaults to all artifacts when no pattern is provided', () => { + expect(parsePatterns(' \n ')).toEqual(['*']); + }); +}); + +describe('globToRegExp', () => { + test('supports wildcards while treating other regular expression characters literally', () => { + const matcher = globToRegExp('release.v1+?-*'); + + expect(matcher.test('release.v1+a-linux')).toBe(true); + expect(matcher.test('releaseXv1+a-linux')).toBe(false); + expect(matcher.test('release.v1+-linux')).toBe(false); + }); +}); + +describe('selectArtifacts', () => { + test('selects matching artifact names and sorts them', () => { + const artifacts = [ + { id: 3, name: 'documentation' }, + { id: 2, name: 'build-windows-x64' }, + { id: 4, name: 'coverage' }, + { id: 1, name: 'build-linux-x64' }, + ]; + + const selected = selectArtifacts(artifacts, ['build-*-x64', 'documentation']); + + expect(selected).toEqual([ + { id: 1, name: 'build-linux-x64' }, + { id: 2, name: 'build-windows-x64' }, + { id: 3, name: 'documentation' }, + ]); + }); +}); + +describe('escapeLinkLabel', () => { + test('escapes brackets and backslashes in artifact names', () => { + expect(escapeLinkLabel('build[linux]\\x64')).toBe('build\\[linux\\]\\\\x64'); + }); +}); + +describe('normalizePrNumber', () => { + test('normalizes valid numbers and allows an omitted value', () => { + expect(normalizePrNumber(42)).toBe('42'); + expect(normalizePrNumber(undefined)).toBe(''); + }); + + test('rejects a non-numeric value', () => { + expect(() => normalizePrNumber('PR-42')).toThrow('Invalid PR number value: PR-42'); + }); +}); + +describe('resolvePrNumber', () => { + test('uses an explicitly provided pull request number', async () => { + const github = createMockGithub(); + const context = createMockContext(); + + await expect(resolvePrNumber(github, context, { event: 'push' }, '99')).resolves.toBe('99'); + expect(github.paginate).not.toHaveBeenCalled(); + }); + + test('uses the pull request attached to the source workflow run', async () => { + const github = createMockGithub(); + const context = createMockContext(); + + await expect(resolvePrNumber(github, context, createSourceRun(), '')).resolves.toBe('42'); + expect(github.paginate).not.toHaveBeenCalled(); + }); + + test('falls back to an open pull request with the matching head SHA', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const sourceRun = createSourceRun({ pull_requests: [] }); + github.paginate.mockResolvedValue([ + { number: 10, head: { sha: 'other-sha' } }, + { number: 20, head: { sha: 'head-sha' } }, + ]); + + await expect(resolvePrNumber(github, context, sourceRun, '')).resolves.toBe('20'); + expect(github.paginate).toHaveBeenCalledWith(github.rest.pulls.list, { + owner: 'test-org', + repo: 'test-repo', + state: 'open', + head: 'contributor:feature', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + }); + + test('prefers a pull request targeting the current repository when the SHA changed', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const sourceRun = createSourceRun({ + head_repository: { + full_name: 'contributor/test-repo', + owner: { name: 'contributor' }, + }, + pull_requests: [], + }); + github.paginate.mockResolvedValue([ + { number: 10, base: { repo: { full_name: 'somewhere/else' } } }, + { number: 20, base: { repo: { full_name: 'test-org/test-repo' } } }, + ]); + + await expect(resolvePrNumber(github, context, sourceRun, '')).resolves.toBe('20'); + }); + + test('uses the most recently updated candidate as a final fallback', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const sourceRun = createSourceRun({ + head_repository: { full_name: 'contributor/test-repo' }, + pull_requests: [], + }); + github.paginate.mockResolvedValue([{ number: 10 }]); + + await expect(resolvePrNumber(github, context, sourceRun, '')).resolves.toBe('10'); + }); + + test('rejects a source workflow that was not triggered by a pull request', async () => { + const github = createMockGithub(); + const context = createMockContext(); + + await expect(resolvePrNumber(github, context, { event: 'push' }, '')).rejects.toThrow( + 'Source workflow run event is "push", not "pull_request".' + ); + }); + + test('reports an unknown source event when workflow metadata omits it', async () => { + const github = createMockGithub(); + const context = createMockContext(); + + await expect(resolvePrNumber(github, context, {}, '')).rejects.toThrow( + 'Source workflow run event is "unknown", not "pull_request".' + ); + }); + + test('reports source details when no pull request can be resolved', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const sourceRun = createSourceRun({ + head_branch: '', + head_repository: null, + head_sha: '', + pull_requests: [], + }); + + await expect(resolvePrNumber(github, context, sourceRun, '')).rejects.toThrow( + 'Unable to determine PR number for pull_request workflow run. ' + + 'head_repository= head_branch= head_sha= payload_pull_requests=0' + ); + }); +}); + +describe('formatComment', () => { + test('builds direct links for selected artifacts', () => { + const comment = formatComment({ + artifacts: [ + { id: 10, name: 'build[linux]' }, + { id: 20, name: 'build-windows' }, + ], + owner: 'test-org', + repo: 'test-repo', + runId: '123', + serverUrl: 'https://github.example.com/', + title: 'Downloads', + }); + + expect(comment).toBe([ + '## Downloads', + '', + 'Artifacts from [workflow run 123](https://github.example.com/test-org/test-repo/actions/runs/123):', + '', + '- [build\\[linux\\]](https://github.example.com/test-org/test-repo/actions/runs/123/artifacts/10)', + '- [build-windows](https://github.example.com/test-org/test-repo/actions/runs/123/artifacts/20)', + '', + 'You must be signed in to GitHub to download workflow artifacts.', + ].join('\n')); + }); + + test('links to the workflow run when no artifacts match', () => { + const comment = formatComment({ + artifacts: [], + owner: 'test-org', + repo: 'test-repo', + runId: '123', + serverUrl: 'https://github.com', + title: 'Build artifacts', + }); + + expect(comment).toBe([ + '## Build artifacts', + '', + 'No artifacts matched the configured patterns in [workflow run 123](https://github.com/test-org/test-repo/actions/runs/123).', + ].join('\n')); + }); +}); + +describe('listArtifacts', () => { + test('uses pagination to retrieve every artifact from the requested run', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const artifacts = [{ id: 1, name: 'build' }]; + github.paginate.mockResolvedValue(artifacts); + + await expect(listArtifacts(github, context, 456)).resolves.toEqual(artifacts); + expect(github.rest.actions.listWorkflowRunArtifacts.endpoint.merge).toHaveBeenCalledWith({ + owner: 'test-org', + repo: 'test-repo', + run_id: 456, + per_page: 100, + }); + expect(github.paginate).toHaveBeenCalledWith({ endpoint: 'artifacts' }); + }); +}); + +describe('getWorkflowRun', () => { + test('gets the source workflow run from the current repository', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const sourceRun = createSourceRun(); + github.rest.actions.getWorkflowRun.mockResolvedValue({ data: sourceRun }); + + await expect(getWorkflowRun(github, context, 456)).resolves.toEqual(sourceRun); + expect(github.rest.actions.getWorkflowRun).toHaveBeenCalledWith({ + owner: 'test-org', + repo: 'test-repo', + run_id: 456, + }); + }); +}); + +describe('artifactCommentAction', () => { + test('filters artifacts and sets comment outputs from configured inputs', async () => { + const github = createMockGithub(); + const context = createMockContext({ serverUrl: 'https://github.example.com' }); + const core = createMockCore(); + github.paginate.mockResolvedValue([ + { id: 1, name: 'build-linux' }, + { id: 2, name: 'coverage' }, + { id: 3, name: 'build-windows' }, + ]); + process.env.INPUT_ARTIFACT_PATTERNS = 'build-*'; + process.env.INPUT_PR_NUMBER = '77'; + process.env.INPUT_RUN_ID = '456'; + process.env.INPUT_TITLE = 'Test builds'; + + await artifactCommentAction({ github, context, core }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.setOutput).toHaveBeenCalledWith('ARTIFACT_COUNT', '2'); + expect(core.setOutput).toHaveBeenCalledWith('ARTIFACT_NAMES', 'build-linux\nbuild-windows'); + expect(core.setOutput).toHaveBeenCalledWith( + 'COMMENT_BODY', + expect.stringContaining('## Test builds') + ); + expect(core.setOutput).toHaveBeenCalledWith( + 'COMMENT_BODY', + expect.stringContaining('https://github.example.com/test-org/test-repo/actions/runs/456/artifacts/1') + ); + expect(core.setOutput).toHaveBeenCalledWith('PR_NUMBER', '77'); + expect(consoleMocks.consoleOutput).toContain('Found 3 artifact(s); selected 2.'); + }); + + test('uses context defaults and reports when no artifacts match', async () => { + const github = createMockGithub(); + const context = createMockContext({ serverUrl: undefined }); + const core = createMockCore(); + github.paginate.mockResolvedValue([]); + + await artifactCommentAction({ github, context, core }); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.setOutput).toHaveBeenCalledWith('ARTIFACT_COUNT', '0'); + expect(core.setOutput).toHaveBeenCalledWith('ARTIFACT_NAMES', ''); + expect(core.setOutput).toHaveBeenCalledWith( + 'COMMENT_BODY', + expect.stringContaining('https://github.com/test-org/test-repo/actions/runs/123') + ); + expect(core.setOutput).toHaveBeenCalledWith('PR_NUMBER', '42'); + }); + + test('fails for an invalid workflow run ID', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const core = createMockCore(); + process.env.INPUT_RUN_ID = 'not-a-run'; + + await artifactCommentAction({ github, context, core }); + + expect(core.setFailed).toHaveBeenCalledWith( + 'Failed to build artifact comment: Invalid workflow run ID: "not-a-run".' + ); + expect(github.paginate).not.toHaveBeenCalled(); + }); + + test('reports GitHub API failures', async () => { + const github = createMockGithub(); + const context = createMockContext(); + const core = createMockCore(); + github.paginate.mockRejectedValue(new Error('API unavailable')); + + await artifactCommentAction({ github, context, core }); + + expect(core.setFailed).toHaveBeenCalledWith( + 'Failed to build artifact comment: API unavailable' + ); + }); +});