diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 57d7ab1bdb9..9741a99a84e 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -7,7 +7,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo, validateRepo } = require("./repo_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); -const { ERR_NOT_FOUND } = require("./error_codes.cjs"); +const { getIssueDetails, addIssueThreadComment, closeIssue: closeIssueRest } = require("./close_rest_helpers.cjs"); const { createCloseEntityHandler, buildCommentBody, ISSUE_CONFIG } = require("./close_entity_helpers.cjs"); const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -107,48 +107,6 @@ async function markIssueAsDuplicate(github, duplicateNodeId, canonicalOwner, can ); } -/** - * Get issue details using REST API - * @param {any} github - GitHub REST API instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @returns {Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}>} Issue details - */ -async function getIssueDetails(github, owner, repo, issueNumber) { - const { data: issue } = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); - - if (!issue) { - throw new Error(`${ERR_NOT_FOUND}: Issue #${issueNumber} not found in ${owner}/${repo}`); - } - - return issue; -} - -/** - * Add comment to a GitHub Issue using REST API - * @param {any} github - GitHub REST API instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @param {string} message - Comment body - * @returns {Promise<{id: number, html_url: string}>} Comment details - */ -async function addIssueComment(github, owner, repo, issueNumber, message) { - const { data: comment } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: message, - }); - - return comment; -} - /** * Close a GitHub Issue using REST API * @param {any} github - GitHub REST API instance @@ -159,13 +117,7 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { * @returns {Promise<{number: number, html_url: string, title: string, node_id: string}>} Issue details */ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentMetadata, useIssueIntent) { - const baseParams = { - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: (stateReason || "COMPLETED").toLowerCase(), - }; + const normalizedStateReason = (stateReason || "COMPLETED").toLowerCase(); const hasIntentMetadata = Boolean(intentMetadata && Object.keys(intentMetadata).length > 0); if (useIssueIntent && hasIntentMetadata) { @@ -175,7 +127,7 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM repo, issue_number: issueNumber, state: { value: "closed", ...intentMetadata }, - state_reason: baseParams.state_reason, + state_reason: normalizedStateReason, }); return issue; } catch (error) { @@ -183,9 +135,7 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM } } - const { data: issue } = await github.rest.issues.update(baseParams); - - return issue; + return await closeIssueRest(github, owner, repo, issueNumber, normalizedStateReason); } /** @@ -302,7 +252,7 @@ async function main(config = {}) { return buildCommentBody(sanitizedBody, triggeringIssueNumber, triggeringPRNumber); }, - addComment: addIssueComment, + addComment: addIssueThreadComment, closeEntity(github, owner, repo, entityNumber, item) { // Determine effective state_reason, validating against permitted values when applicable. diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index 252b8ec1e8c..ac718bac5e1 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -2,6 +2,7 @@ /// const { sanitizeContent } = require("./sanitize_content.cjs"); +const { addIssueThreadComment, closeIssue } = require("./close_rest_helpers.cjs"); const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs"); const { searchOlderEntitiesByMarker } = require("./close_older_search_helpers.cjs"); @@ -81,19 +82,14 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { core.info(`Adding comment to issue #${issueNumber} in ${owner}/${repo}`); core.info(` Comment length: ${message.length} characters`); - const result = await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: sanitizeContent(message), - }); + const comment = await addIssueThreadComment(github, owner, repo, issueNumber, sanitizeContent(message)); - core.info(` ✓ Comment created successfully with ID: ${result.data.id}`); - core.info(` Comment URL: ${result.data.html_url}`); + core.info(` ✓ Comment created successfully with ID: ${comment.id}`); + core.info(` Comment URL: ${comment.html_url}`); return { - id: result.data.id, - html_url: result.data.html_url, + id: comment.id, + html_url: comment.html_url, }; } @@ -108,20 +104,14 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { async function closeIssueAsNotPlanned(github, owner, repo, issueNumber) { core.info(`Closing issue #${issueNumber} in ${owner}/${repo} as "not planned"`); - const result = await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: "not_planned", - }); + const issue = await closeIssue(github, owner, repo, issueNumber, "not_planned"); - core.info(` ✓ Issue #${result.data.number} closed successfully`); - core.info(` Issue URL: ${result.data.html_url}`); + core.info(` ✓ Issue #${issue.number} closed successfully`); + core.info(` Issue URL: ${issue.html_url}`); return { - number: result.data.number, - html_url: result.data.html_url, + number: issue.number, + html_url: issue.html_url, }; } diff --git a/actions/setup/js/close_older_pull_requests.cjs b/actions/setup/js/close_older_pull_requests.cjs index 475fbf0bf12..0e7850fa798 100644 --- a/actions/setup/js/close_older_pull_requests.cjs +++ b/actions/setup/js/close_older_pull_requests.cjs @@ -2,6 +2,7 @@ /// const { sanitizeContent } = require("./sanitize_content.cjs"); +const { addIssueThreadComment, closePullRequest: closePullRequestRest } = require("./close_rest_helpers.cjs"); const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs"); const { searchOlderEntitiesByMarker } = require("./close_older_search_helpers.cjs"); @@ -78,19 +79,14 @@ async function addPullRequestComment(github, owner, repo, prNumber, message) { core.info(`Adding comment to pull request #${prNumber} in ${owner}/${repo}`); core.info(` Comment length: ${message.length} characters`); - const result = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: sanitizeContent(message), - }); + const comment = await addIssueThreadComment(github, owner, repo, prNumber, sanitizeContent(message)); - core.info(` ✓ Comment created successfully with ID: ${result.data.id}`); - core.info(` Comment URL: ${result.data.html_url}`); + core.info(` ✓ Comment created successfully with ID: ${comment.id}`); + core.info(` Comment URL: ${comment.html_url}`); return { - id: result.data.id, - html_url: result.data.html_url, + id: comment.id, + html_url: comment.html_url, }; } @@ -105,19 +101,14 @@ async function addPullRequestComment(github, owner, repo, prNumber, message) { async function closePullRequest(github, owner, repo, prNumber) { core.info(`Closing pull request #${prNumber} in ${owner}/${repo}`); - const result = await github.rest.pulls.update({ - owner, - repo, - pull_number: prNumber, - state: "closed", - }); + const pr = await closePullRequestRest(github, owner, repo, prNumber); - core.info(` ✓ Pull request #${result.data.number} closed successfully`); - core.info(` Pull request URL: ${result.data.html_url}`); + core.info(` ✓ Pull request #${pr.number} closed successfully`); + core.info(` Pull request URL: ${pr.html_url}`); return { - number: result.data.number, - html_url: result.data.html_url, + number: pr.number, + html_url: pr.html_url, }; } diff --git a/actions/setup/js/close_pull_request.cjs b/actions/setup/js/close_pull_request.cjs index 6522d0cc477..ace78199691 100644 --- a/actions/setup/js/close_pull_request.cjs +++ b/actions/setup/js/close_pull_request.cjs @@ -2,7 +2,7 @@ /// const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); -const { ERR_NOT_FOUND } = require("./error_codes.cjs"); +const { getPullRequestDetails, addIssueThreadComment, closePullRequest } = require("./close_rest_helpers.cjs"); const { createCloseEntityHandler, checkLabelFilter, buildCommentBody, PULL_REQUEST_CONFIG } = require("./close_entity_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); @@ -10,67 +10,6 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction */ -/** - * Get pull request details using REST API - * @param {any} github - GitHub REST API instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} prNumber - Pull request number - * @returns {Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}>} Pull request details - */ -async function getPullRequestDetails(github, owner, repo, prNumber) { - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - - if (!pr) { - throw new Error(`${ERR_NOT_FOUND}: Pull request #${prNumber} not found in ${owner}/${repo}`); - } - - return pr; -} - -/** - * Add comment to a GitHub Pull Request using REST API - * @param {any} github - GitHub REST API instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} prNumber - Pull request number - * @param {string} message - Comment body - * @returns {Promise<{id: number, html_url: string}>} Comment details - */ -async function addPullRequestComment(github, owner, repo, prNumber, message) { - const { data: comment } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: message, - }); - - return comment; -} - -/** - * Close a GitHub Pull Request using REST API - * @param {any} github - GitHub REST API instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} prNumber - Pull request number - * @returns {Promise<{number: number, html_url: string, title: string}>} Pull request details - */ -async function closePullRequest(github, owner, repo, prNumber) { - const { data: pr } = await github.rest.pulls.update({ - owner, - repo, - pull_number: prNumber, - state: "closed", - }); - - return pr; -} - /** * Handler factory for close-pull-request safe outputs * @type {HandlerFactoryFunction} @@ -142,7 +81,7 @@ async function main(config = {}) { return buildCommentBody(sanitizedBody, triggeringIssueNumber, triggeringPRNumber); }, - addComment: addPullRequestComment, + addComment: addIssueThreadComment, closeEntity(github, owner, repo, prNumber) { core.info(`Closing PR #${prNumber} in ${owner}/${repo}`); diff --git a/actions/setup/js/close_rest_helpers.cjs b/actions/setup/js/close_rest_helpers.cjs new file mode 100644 index 00000000000..89f21ec8888 --- /dev/null +++ b/actions/setup/js/close_rest_helpers.cjs @@ -0,0 +1,123 @@ +// @ts-check +/// + +/** + * Shared REST wrappers used by the close-entity flows (close_issue, close_pull_request, + * close_older_*, close_expired_*). These functions own the raw Octokit calls so the + * individual handlers only deal with entity-specific behavior (logging, sanitization, + * result shaping). + */ + +const { ERR_NOT_FOUND } = require("./error_codes.cjs"); + +/** + * Get issue details using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @returns {Promise} Issue details + */ +async function getIssueDetails(github, owner, repo, issueNumber) { + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + if (!issue) { + throw new Error(`${ERR_NOT_FOUND}: Issue #${issueNumber} not found in ${owner}/${repo}`); + } + + return issue; +} + +/** + * Get pull request details using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} prNumber - Pull request number + * @returns {Promise} Pull request details + */ +async function getPullRequestDetails(github, owner, repo, prNumber) { + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + if (!pr) { + throw new Error(`${ERR_NOT_FOUND}: Pull request #${prNumber} not found in ${owner}/${repo}`); + } + + return pr; +} + +/** + * Add a comment to an issue thread (issues and pull requests share this endpoint) + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue or pull request number + * @param {string} body - Comment body (callers are responsible for sanitization) + * @returns {Promise} Comment details + */ +async function addIssueThreadComment(github, owner, repo, issueNumber, body) { + const { data: comment } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + + return comment; +} + +/** + * Close a GitHub Issue using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @param {string} [stateReason] - Close reason: "completed", "not_planned" or "duplicate" + * @returns {Promise} Issue details + */ +async function closeIssue(github, owner, repo, issueNumber, stateReason = "not_planned") { + const { data: issue } = await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: stateReason, + }); + + return issue; +} + +/** + * Close a GitHub Pull Request using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} prNumber - Pull request number + * @returns {Promise} Pull request details + */ +async function closePullRequest(github, owner, repo, prNumber) { + const { data: pr } = await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: "closed", + }); + + return pr; +} + +module.exports = { + getIssueDetails, + getPullRequestDetails, + addIssueThreadComment, + closeIssue, + closePullRequest, +}; diff --git a/actions/setup/js/close_rest_helpers.test.cjs b/actions/setup/js/close_rest_helpers.test.cjs new file mode 100644 index 00000000000..c676e0c083f --- /dev/null +++ b/actions/setup/js/close_rest_helpers.test.cjs @@ -0,0 +1,106 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { getIssueDetails, getPullRequestDetails, addIssueThreadComment, closeIssue, closePullRequest } from "./close_rest_helpers.cjs"; + +describe("close_rest_helpers", () => { + let mockGithub; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = { + rest: { + issues: { + get: vi.fn(), + createComment: vi.fn(), + update: vi.fn(), + }, + pulls: { + get: vi.fn(), + update: vi.fn(), + }, + }, + }; + }); + + describe("getIssueDetails", () => { + it("fetches the issue and returns its data", async () => { + mockGithub.rest.issues.get.mockResolvedValue({ data: { number: 123, title: "Test issue" } }); + + const result = await getIssueDetails(mockGithub, "owner", "repo", 123); + + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ owner: "owner", repo: "repo", issue_number: 123 }); + expect(result).toEqual({ number: 123, title: "Test issue" }); + }); + + it("throws a not-found error when the issue is missing", async () => { + mockGithub.rest.issues.get.mockResolvedValue({ data: null }); + + await expect(getIssueDetails(mockGithub, "owner", "repo", 123)).rejects.toThrow("ERR_NOT_FOUND: Issue #123 not found in owner/repo"); + }); + }); + + describe("getPullRequestDetails", () => { + it("fetches the pull request and returns its data", async () => { + mockGithub.rest.pulls.get.mockResolvedValue({ data: { number: 42, title: "Test PR" } }); + + const result = await getPullRequestDetails(mockGithub, "owner", "repo", 42); + + expect(mockGithub.rest.pulls.get).toHaveBeenCalledWith({ owner: "owner", repo: "repo", pull_number: 42 }); + expect(result).toEqual({ number: 42, title: "Test PR" }); + }); + + it("throws a not-found error when the pull request is missing", async () => { + mockGithub.rest.pulls.get.mockResolvedValue({ data: null }); + + await expect(getPullRequestDetails(mockGithub, "owner", "repo", 42)).rejects.toThrow("ERR_NOT_FOUND: Pull request #42 not found in owner/repo"); + }); + }); + + describe("addIssueThreadComment", () => { + it("creates a comment on the issue thread without modifying the body", async () => { + mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 1, html_url: "https://github.com/owner/repo/issues/123#issuecomment-1" } }); + + const result = await addIssueThreadComment(mockGithub, "owner", "repo", 123, "Hello"); + + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "owner", repo: "repo", issue_number: 123, body: "Hello" }); + expect(result).toEqual({ id: 1, html_url: "https://github.com/owner/repo/issues/123#issuecomment-1" }); + }); + }); + + describe("closeIssue", () => { + it("closes the issue with the provided state reason", async () => { + mockGithub.rest.issues.update.mockResolvedValue({ data: { number: 123, html_url: "https://github.com/owner/repo/issues/123" } }); + + const result = await closeIssue(mockGithub, "owner", "repo", 123, "completed"); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 123, + state: "closed", + state_reason: "completed", + }); + expect(result).toEqual({ number: 123, html_url: "https://github.com/owner/repo/issues/123" }); + }); + + it("defaults the state reason to not_planned", async () => { + mockGithub.rest.issues.update.mockResolvedValue({ data: { number: 123 } }); + + await closeIssue(mockGithub, "owner", "repo", 123); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ state_reason: "not_planned" })); + }); + }); + + describe("closePullRequest", () => { + it("closes the pull request", async () => { + mockGithub.rest.pulls.update.mockResolvedValue({ data: { number: 42, html_url: "https://github.com/owner/repo/pull/42" } }); + + const result = await closePullRequest(mockGithub, "owner", "repo", 42); + + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({ owner: "owner", repo: "repo", pull_number: 42, state: "closed" }); + expect(result).toEqual({ number: 42, html_url: "https://github.com/owner/repo/pull/42" }); + }); + }); +}); diff --git a/actions/setup/js/expired_entity_handler_factory.cjs b/actions/setup/js/expired_entity_handler_factory.cjs index 9ce4a6ac46a..ed20aa330cd 100644 --- a/actions/setup/js/expired_entity_handler_factory.cjs +++ b/actions/setup/js/expired_entity_handler_factory.cjs @@ -4,6 +4,7 @@ const { generateExpiredEntityFooter, getExpiredEntityCautionAlert } = require("./generate_footer.cjs"); const { formatDateInProjectTimeZone } = require("./project_timezone.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { addIssueThreadComment: addIssueThreadCommentRest, closeIssue: closeIssueRest, closePullRequest: closePullRequestRest } = require("./close_rest_helpers.cjs"); /** * @param {string} label @@ -53,14 +54,7 @@ function createExpiredEntityClosingMessage({ entity, entityNoun, workflowName, w * @returns {Promise} Comment details */ async function addIssueThreadComment(github, owner, repo, issueNumber, message) { - const result = await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: issueNumber, - body: sanitizeContent(message), - }); - - return result.data; + return await addIssueThreadCommentRest(github, owner, repo, issueNumber, sanitizeContent(message)); } /** @@ -72,15 +66,7 @@ async function addIssueThreadComment(github, owner, repo, issueNumber, message) * @returns {Promise} Issue details */ async function closeIssue(github, owner, repo, issueNumber) { - const result = await github.rest.issues.update({ - owner: owner, - repo: repo, - issue_number: issueNumber, - state: "closed", - state_reason: "not_planned", - }); - - return result.data; + return await closeIssueRest(github, owner, repo, issueNumber, "not_planned"); } /** @@ -92,14 +78,7 @@ async function closeIssue(github, owner, repo, issueNumber) { * @returns {Promise} Pull request details */ async function closePullRequest(github, owner, repo, prNumber) { - const result = await github.rest.pulls.update({ - owner: owner, - repo: repo, - pull_number: prNumber, - state: "closed", - }); - - return result.data; + return await closePullRequestRest(github, owner, repo, prNumber); } /**