diff --git a/__tests__/issueCommentTest/meow.test.ts b/__tests__/issueCommentTest/meow.test.ts new file mode 100644 index 0000000..df67e2b --- /dev/null +++ b/__tests__/issueCommentTest/meow.test.ts @@ -0,0 +1,442 @@ +import * as core from '@actions/core' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { handleIssueComment } from '../../src/issueComment/handleIssueComment' +import { meowConfig } from '../../src/issueComment/meow' +import * as comments from '../../src/utils/comments' +import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' +import * as utils from '../testUtils' + +const catApi = 'https://api.thecatapi.com/v1/images/search' + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + jest.restoreAllMocks() +}) +afterAll(() => server.close()) + +function contextFor(body: string) { + issueCommentEvent.comment.body = body + return new utils.MockContext(issueCommentEvent) +} + +describe('/meow', () => { + let createComment: jest.SpiedFunction + + beforeEach(() => { + utils.setupActionsEnv('/meow') + meowConfig.timeoutMs = 10_000 + meowConfig.retryDelayMs = 0 + meowConfig.maxAttempts = 3 + createComment = jest.spyOn(comments, 'createComment').mockResolvedValue() + }) + + it('comments with a cat image for a standalone /meow', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledTimes(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('safely renders a url that contains parentheses', async () => { + const url = 'https://example.test/cats/a_(b).jpg' + server.use(http.get(catApi, () => HttpResponse.json([{ url }]))) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + `![cat](<${url}>)`, + ) + }) + + it.each(['/meowvie', '/meow-debug', '/meow cat', 'please run /meow later'])( + 'does not trigger for %p', + async (body) => { + await handleIssueComment(contextFor(body)) + expect(createComment).not.toHaveBeenCalled() + }, + ) + + it('matches a standalone /meow once in a CRLF comment', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + + await handleIssueComment(contextFor('/meow\r\n/something-else')) + + expect(createComment).toHaveBeenCalledTimes(1) + }) + + it('retries a transient status and then succeeds', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + if (calls === 1) + return new HttpResponse(null, { status: 503 }) + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(2) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('degrades to a note when the cat api keeps failing', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 500 }) + }), + ) + const warning = jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(meowConfig.maxAttempts) + expect(warning).toHaveBeenCalled() + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note on a network error', async () => { + server.use(http.get(catApi, () => HttpResponse.error())) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the cat api times out and does not retry', async () => { + meowConfig.timeoutMs = 50 + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new Promise(() => {}) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the response has no usable url', async () => { + server.use(http.get(catApi, () => HttpResponse.json([]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('sends the api key header only when configured', async () => { + process.env['INPUT_CAT-API-KEY'] = 'secret-key' + const setSecret = jest.spyOn(core, 'setSecret').mockImplementation(() => {}) + let seenKey: string | null = null + server.use( + http.get(catApi, ({ request }) => { + seenKey = request.headers.get('x-api-key') + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(setSecret).toHaveBeenCalledWith('secret-key') + expect(seenKey).toBe('secret-key') + }) + + it('fails the action when the github comment write fails', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }])), + ) + createComment.mockRejectedValue(new Error('could not add comment: boom')) + const setFailed = jest.spyOn(core, 'setFailed').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(setFailed).toHaveBeenCalledWith( + expect.stringContaining('could not add comment'), + ) + }) + + it('does not send an api key header when none is configured', async () => { + let sentKey: boolean | null = null + server.use( + http.get(catApi, ({ request }) => { + sentKey = request.headers.has('x-api-key') + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(sentKey).toBe(false) + }) + + it('does not retry a client error', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 400 }) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('requests a single medium image', async () => { + let requestedUrl: string | null = null + server.use( + http.get(catApi, ({ request }) => { + requestedUrl = request.url + return HttpResponse.json([{ url: 'https://cataas.com/cat.jpg' }]) + }), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(requestedUrl).toContain('limit=1') + expect(requestedUrl).toContain('size=med') + }) + + it('retries a network error up to the attempt limit', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return HttpResponse.error() + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(meowConfig.maxAttempts) + }) + + it('degrades to a note for a non-https image url', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'http://cataas.com/cat.jpg' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note for a url that embeds credentials', async () => { + server.use( + http.get(catApi, () => + HttpResponse.json([{ url: 'https://user:pass@cataas.com/cat.jpg' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('does not follow a redirect or forward the key', async () => { + process.env['INPUT_CAT-API-KEY'] = 'secret-key' + // the redirect target is intentionally not mocked: onUnhandledRequest error + // fails the test if the request is ever followed there + server.use( + http.get(catApi, () => + new HttpResponse(null, { + status: 302, + headers: { location: 'https://redirect.example/cat' }, + })), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the first item has no url', async () => { + server.use(http.get(catApi, () => HttpResponse.json([{}]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note when the url does not parse', async () => { + server.use( + http.get(catApi, () => HttpResponse.json([{ url: 'not a url' }])), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('degrades to a note for an excessively long url', async () => { + const longUrl = `https://cataas.com/${'a'.repeat(5000)}.jpg` + server.use(http.get(catApi, () => HttpResponse.json([{ url: longUrl }]))) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('does not retry a rate limit', async () => { + let calls = 0 + server.use( + http.get(catApi, () => { + calls++ + return new HttpResponse(null, { status: 429 }) + }), + ) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(calls).toBe(1) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + 'The cat API is unavailable right now.', + ) + }) + + it('cancels the response body before retrying', async () => { + let cancelled = false + const stream = new ReadableStream({ + cancel() { + cancelled = true + }, + }) + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(stream, { status: 503 })) + .mockResolvedValueOnce( + Response.json([{ url: 'https://cataas.com/cat.jpg' }]), + ) + + await handleIssueComment(contextFor('/meow')) + + expect(cancelled).toBe(true) + expect(createComment).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 1, + '![cat]()', + ) + }) + + it('cancels the response body for a non-retryable status', async () => { + let cancelled = false + const stream = new ReadableStream({ + cancel() { + cancelled = true + }, + }) + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(stream, { status: 400 })) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + + await handleIssueComment(contextFor('/meow')) + + expect(cancelled).toBe(true) + }) +}) diff --git a/action.yml b/action.yml index 2f04dba..6dd7c5f 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,9 @@ inputs: merge-method: description: "Strategy for Prow-github-actions to take when merging a pull request using the lgtm cron-job. Can be 'squash', 'rebase', or 'merge'. Defaults to 'merge'" required: false + cat-api-key: + description: 'Optional API key for the /meow image provider (https://thecatapi.com), sent as the x-api-key header. Provide it from a repository secret. The action registers it for runner masking before use and never intentionally includes it in request URLs or GitHub comments.' + required: false branding: color: blue icon: anchor diff --git a/dist/index.js b/dist/index.js index e044207..41d2265 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1032,6 +1032,7 @@ const assign_1 = __nccwpck_require__(7752); const cc_1 = __nccwpck_require__(423); const close_1 = __nccwpck_require__(6273); const lock_1 = __nccwpck_require__(8886); +const meow_1 = __nccwpck_require__(8841); const milestone_1 = __nccwpck_require__(2771); const reopen_1 = __nccwpck_require__(1328); const retitle_1 = __nccwpck_require__(7068); @@ -1118,6 +1119,10 @@ async function handleIssueComment(context = github.context) { return await (0, milestone_1.milestone)(context).catch(async (e) => { return e; }); + case '/meow': + return await (0, meow_1.meow)(context).catch(async (e) => { + return e; + }); case '': return new Error(`please provide a list of space delimited commands / jobs to run. None found`); default: @@ -1294,6 +1299,173 @@ async function lock(context = github.context) { } +/***/ }), + +/***/ 8841: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.meowConfig = void 0; +exports.meow = meow; +const core = __importStar(__nccwpck_require__(7484)); +const github = __importStar(__nccwpck_require__(3228)); +const rest_1 = __nccwpck_require__(5772); +const comments_1 = __nccwpck_require__(2666); +const catApi = 'https://api.thecatapi.com/v1/images/search?limit=1&size=med'; +// a line of exactly /meow, not /meowvie or a mention +const meowCommand = /^[\t ]*\/meow[\t ]*$/m; +// bounded so a slow provider cannot stall the runner; exported so tests can shrink the waits +exports.meowConfig = { + timeoutMs: 5000, + maxAttempts: 3, + retryDelayMs: 500, +}; +/** + * /meow replies with a random cat image + * + * @param context - the github actions event context + */ +async function meow(context = github.context) { + var _a, _b; + if (!hasMeowCommand((_a = context.payload.comment) === null || _a === void 0 ? void 0 : _a.body)) + return; + const token = core.getInput('github-token', { required: true }); + const octokit = new rest_1.Octokit({ auth: token }); + const issueNumber = (_b = context.payload.issue) === null || _b === void 0 ? void 0 : _b.number; + if (issueNumber === undefined) { + throw new Error(`github context payload missing issue number: ${context.payload}`); + } + // a provider outage degrades to a note; only the github write can fail the action + let body; + try { + const image = await fetchCatImage(); + body = `![cat](<${image.href}>)`; + } + catch (error) { + core.warning(`Could not fetch a cat image: ${error}`); + body = 'The cat API is unavailable right now.'; + } + await (0, comments_1.createComment)(octokit, context, issueNumber, body); +} +// hasMeowCommand reports whether the body has a standalone /meow line +function hasMeowCommand(body) { + return typeof body === 'string' && meowCommand.test(body); +} +async function fetchCatImage() { + const headers = { accept: 'application/json' }; + const key = core.getInput('cat-api-key', { required: false }); + if (key !== '') { + core.setSecret(key); + headers['x-api-key'] = key; + } + let lastError = new Error('cat api was not reached'); + for (let attempt = 1; attempt <= exports.meowConfig.maxAttempts; attempt++) { + if (attempt > 1) + await delay(exports.meowConfig.retryDelayMs); + try { + const response = await fetch(catApi, { + headers, + // refuse redirects so the api key cannot leak cross-origin + redirect: 'manual', + signal: AbortSignal.timeout(exports.meowConfig.timeoutMs), + }); + if (response.ok && response.type !== 'opaqueredirect') + return parseCatImage(await response.json()); + // undici holds the socket until the body is read + await cancelResponseBody(response); + // retry a 5xx; a 429, a redirect, and other 4xx fall back + const error = new Error(`cat api responded with ${response.status || 'a redirect'}`); + if (response.status >= 500) { + lastError = error; + continue; + } + throw error; + } + catch (error) { + // retry a network failure; a timeout has spent its deadline + if (!(error instanceof TypeError)) + throw error; + lastError = error; + } + } + throw lastError; +} +async function cancelResponseBody(response) { + var _a; + try { + await ((_a = response.body) === null || _a === void 0 ? void 0 : _a.cancel()); + } + catch (error) { + core.debug(`could not cancel cat api response body: ${error}`); + } +} +// parseCatImage validates the response and returns a usable https url +function parseCatImage(value) { + var _a; + const images = value; + if (!Array.isArray(images) || images.length === 0) + throw new Error('cat api returned no images'); + const url = (_a = images[0]) === null || _a === void 0 ? void 0 : _a.url; + if (typeof url !== 'string') + throw new Error('cat api returned an invalid image record'); + if (url.length > 4096) + throw new Error('cat api returned an excessively long image url'); + let image; + try { + image = new URL(url); + } + catch { + throw new Error('cat api returned an invalid image url'); + } + if (image.protocol !== 'https:' + || image.username !== '' + || image.password !== '') { + throw new Error('cat api returned an unusable image url'); + } + return image; +} +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + + /***/ }), /***/ 2771: @@ -13393,7 +13565,7 @@ function expand(str, isTop) { var isOptions = m.body.indexOf(',') >= 0; if (!isSequence && !isOptions) { // {a},b} - if (m.post.match(/,.*\}/)) { + if (m.post.match(/,(?!,).*\}/)) { str = m.pre + '{' + m.body + escClose + m.post; return expand(str); } diff --git a/docs/commands.md b/docs/commands.md index 7d953e1..d9c51f3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,6 +13,7 @@ Commands | Policy | Description `/lock [resolved / off-topic / too-heated / spam]` | Collaborators | locks the issue / PR with the specified reason `/milestone milestone-name` | Collaborators | Adds issue / PR to an existing milestone `/retitle some new title` | Collaborators | Renames the issue / PR +`/meow` | anyone | replies with a random cat image from [the cat API](https://thecatapi.com) Label Commands | Policy | Description --- | --- | --- @@ -25,6 +26,32 @@ Label Commands | Policy | Description `/priority [label1 label2 ...]` | anyone | adds a priority/<> label(s) if it's defined in [the `.prowlabels.yaml` file](./automatic-merging.md) `/remove [label1 label2 ...]` | Collaborators | removes a specified label(s) on an issue / PR +## Enabling `/meow` + +`/meow` is opt in and calls a third party image provider ([the cat API](https://thecatapi.com)). Anyone who can comment on the repository can invoke it and consume the configured API quota, and the command must be on its own line. It is best effort: if the provider is unavailable it leaves a short note instead of failing the workflow. + +```yaml +permissions: + issues: write + pull-requests: write + +jobs: + prow: + runs-on: ubuntu-latest + steps: + - uses: cncf/prow-github-actions@v2 + with: + prow-commands: /meow + github-token: '${{ secrets.GITHUB_TOKEN }}' + cat-api-key: '${{ secrets.CAT_API_KEY }}' +``` + +The workflow token needs `issues: write` or `pull-requests: write` to post the response, because a new repository's `GITHUB_TOKEN` often defaults to read only. Grant both when the same workflow handles comments on issues and pull requests. + +The `@v2` ref is a floating tag the maintainers move per release. `/meow` ships in the next `v2.x` release, so this example applies once that release is published and the `v2` tag points at it; until then, pin to the release that includes it. + +The API key is optional; unauthenticated access is best effort and may be rate limited by the provider. When set, it is provided from a repository secret and registered for runner masking before use, and is never intentionally included in the request URL or a GitHub comment. + ## OWNERS A simplified version of [Prow's OWNERS](https://go.k8s.io/owners) file is supported. When an OWNERS file is present at the root of the repository, it is used to authorize the /lgtm and /approve commands. See an [example][owners-example] using an OWNERS file. diff --git a/src/issueComment/handleIssueComment.ts b/src/issueComment/handleIssueComment.ts index 2eeea41..d4a1d40 100644 --- a/src/issueComment/handleIssueComment.ts +++ b/src/issueComment/handleIssueComment.ts @@ -14,6 +14,7 @@ import { assign } from './assign' import { cc } from './cc' import { close } from './close' import { lock } from './lock' +import { meow } from './meow' import { milestone } from './milestone' import { reopen } from './reopen' import { retitle } from './retitle' @@ -118,6 +119,11 @@ export async function handleIssueComment(context: Context = github.context): Pro return e }) + case '/meow': + return await meow(context).catch(async (e) => { + return e + }) + case '': return new Error( `please provide a list of space delimited commands / jobs to run. None found`, diff --git a/src/issueComment/meow.ts b/src/issueComment/meow.ts new file mode 100644 index 0000000..acb782c --- /dev/null +++ b/src/issueComment/meow.ts @@ -0,0 +1,151 @@ +import type { Context } from '@actions/github/lib/context' +import * as core from '@actions/core' +import * as github from '@actions/github' + +import { Octokit } from '@octokit/rest' + +import { createComment } from '../utils/comments' + +const catApi = 'https://api.thecatapi.com/v1/images/search?limit=1&size=med' + +// a line of exactly /meow, not /meowvie or a mention +const meowCommand = /^[\t ]*\/meow[\t ]*$/m + +// bounded so a slow provider cannot stall the runner; exported so tests can shrink the waits +export const meowConfig = { + timeoutMs: 5_000, + maxAttempts: 3, + retryDelayMs: 500, +} + +/** + * /meow replies with a random cat image + * + * @param context - the github actions event context + */ +export async function meow(context: Context = github.context): Promise { + if (!hasMeowCommand(context.payload.comment?.body)) + return + + const token = core.getInput('github-token', { required: true }) + const octokit = new Octokit({ auth: token }) + + const issueNumber: number | undefined = context.payload.issue?.number + if (issueNumber === undefined) { + throw new Error( + `github context payload missing issue number: ${context.payload}`, + ) + } + + // a provider outage degrades to a note; only the github write can fail the action + let body: string + try { + const image = await fetchCatImage() + body = `![cat](<${image.href}>)` + } + catch (error) { + core.warning(`Could not fetch a cat image: ${error}`) + body = 'The cat API is unavailable right now.' + } + + await createComment(octokit, context, issueNumber, body) +} + +// hasMeowCommand reports whether the body has a standalone /meow line +function hasMeowCommand(body: unknown): boolean { + return typeof body === 'string' && meowCommand.test(body) +} + +async function fetchCatImage(): Promise { + const headers: Record = { accept: 'application/json' } + const key = core.getInput('cat-api-key', { required: false }) + if (key !== '') { + core.setSecret(key) + headers['x-api-key'] = key + } + + let lastError: unknown = new Error('cat api was not reached') + for (let attempt = 1; attempt <= meowConfig.maxAttempts; attempt++) { + if (attempt > 1) + await delay(meowConfig.retryDelayMs) + + try { + const response = await fetch(catApi, { + headers, + // refuse redirects so the api key cannot leak cross-origin + redirect: 'manual', + signal: AbortSignal.timeout(meowConfig.timeoutMs), + }) + + if (response.ok && response.type !== 'opaqueredirect') + return parseCatImage(await response.json()) + + // undici holds the socket until the body is read + await cancelResponseBody(response) + + // retry a 5xx; a 429, a redirect, and other 4xx fall back + const error = new Error( + `cat api responded with ${response.status || 'a redirect'}`, + ) + if (response.status >= 500) { + lastError = error + continue + } + throw error + } + catch (error) { + // retry a network failure; a timeout has spent its deadline + if (!(error instanceof TypeError)) + throw error + lastError = error + } + } + + throw lastError +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } + catch (error) { + core.debug(`could not cancel cat api response body: ${error}`) + } +} + +// parseCatImage validates the response and returns a usable https url +function parseCatImage(value: unknown): URL { + const images = value as Array<{ url?: unknown }> + if (!Array.isArray(images) || images.length === 0) + throw new Error('cat api returned no images') + + const url = images[0]?.url + if (typeof url !== 'string') + throw new Error('cat api returned an invalid image record') + if (url.length > 4096) + throw new Error('cat api returned an excessively long image url') + + let image: URL + try { + image = new URL(url) + } + catch { + throw new Error('cat api returned an invalid image url') + } + + if ( + image.protocol !== 'https:' + || image.username !== '' + || image.password !== '' + ) { + throw new Error('cat api returned an unusable image url') + } + + return image +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +}