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);
}
/**