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' + ); + }); +});