diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs
index 17ceff32c5..1d0ecd540a 100644
--- a/actions/setup/js/close_expired_discussions.cjs
+++ b/actions/setup/js/close_expired_discussions.cjs
@@ -2,7 +2,8 @@
//
const { getErrorMessage } = require("./error_helpers.cjs");
-const { EXPIRATION_PATTERN, extractExpirationDate } = require("./ephemerals.cjs");
+const { extractExpirationDate } = require("./ephemerals.cjs");
+const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
/**
* Maximum number of discussions to update per run
@@ -23,121 +24,6 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
-/**
- * Search for open discussions with expiration markers
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @returns {Promise<{discussions: Array<{id: string, number: number, title: string, url: string, body: string, createdAt: string}>, stats: {pageCount: number, totalScanned: number, duplicateCount: number}}>}
- */
-async function searchDiscussionsWithExpiration(github, owner, repo) {
- const discussions = [];
- const seenDiscussionIds = new Set(); // Track IDs to avoid duplicates
- let hasNextPage = true;
- let cursor = null;
- let pageCount = 0;
- let totalScanned = 0;
- let duplicateCount = 0;
-
- core.info(`Starting GraphQL search for open discussions in ${owner}/${repo}`);
-
- while (hasNextPage) {
- pageCount++;
- core.info(`Fetching page ${pageCount} of open discussions (cursor: ${cursor || "initial"})`);
-
- const query = `
- query($owner: String!, $repo: String!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussions(first: 100, after: $cursor, states: [OPEN]) {
- pageInfo {
- hasNextPage
- endCursor
- }
- nodes {
- id
- number
- title
- url
- body
- createdAt
- }
- }
- }
- }
- `;
-
- const result = await github.graphql(query, {
- owner: owner,
- repo: repo,
- cursor: cursor,
- });
-
- if (!result || !result.repository || !result.repository.discussions) {
- core.warning(`GraphQL query returned no data at page ${pageCount}`);
- break;
- }
-
- const nodes = result.repository.discussions.nodes || [];
- totalScanned += nodes.length;
- core.info(`Page ${pageCount}: Retrieved ${nodes.length} open discussions (total scanned: ${totalScanned})`);
-
- let agenticCount = 0;
- let withExpirationCount = 0;
-
- // Filter for discussions with agentic workflow markers and expiration comments
- for (const discussion of nodes) {
- // Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
- const agenticPattern = /^> AI generated by/m;
- const isAgenticWorkflow = discussion.body && agenticPattern.test(discussion.body);
-
- if (isAgenticWorkflow) {
- agenticCount++;
- }
-
- if (!isAgenticWorkflow) {
- continue;
- }
-
- // Check if has expiration marker with checked checkbox
- const match = discussion.body ? discussion.body.match(EXPIRATION_PATTERN) : null;
-
- if (match) {
- withExpirationCount++;
-
- // Deduplicate: check if we've already seen this discussion
- if (seenDiscussionIds.has(discussion.id)) {
- core.warning(` Skipping duplicate discussion #${discussion.number} (ID: ${discussion.id}) - already seen in previous page`);
- duplicateCount++;
- continue;
- }
-
- seenDiscussionIds.add(discussion.id);
- core.info(` Found discussion #${discussion.number} with expiration marker: "${match[1]}" - ${discussion.title}`);
- discussions.push(discussion);
- }
- }
-
- core.info(`Page ${pageCount} summary: ${agenticCount} agentic discussions, ${withExpirationCount} with expiration markers`);
-
- hasNextPage = result.repository.discussions.pageInfo.hasNextPage;
- cursor = result.repository.discussions.pageInfo.endCursor;
- }
-
- if (duplicateCount > 0) {
- core.warning(`Found and skipped ${duplicateCount} duplicate discussion(s) across pages`);
- }
-
- core.info(`Search complete: Scanned ${totalScanned} discussions across ${pageCount} pages, found ${discussions.length} unique with expiration markers`);
- return {
- discussions,
- stats: {
- pageCount,
- totalScanned,
- duplicateCount,
- },
- };
-}
-
/**
* Validate discussion creation date
* @param {string} createdAt - ISO 8601 creation date
@@ -237,8 +123,13 @@ async function main() {
core.info(`Searching for expired discussions in ${owner}/${repo}`);
- // Search for discussions with expiration markers
- const { discussions: discussionsWithExpiration, stats: searchStats } = await searchDiscussionsWithExpiration(github, owner, repo);
+ // Search for discussions with expiration markers (enable dedupe for discussions)
+ const { items: discussionsWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, {
+ entityType: "discussions",
+ graphqlField: "discussions",
+ resultKey: "discussions",
+ enableDedupe: true, // Discussions may have duplicates across pages
+ });
if (discussionsWithExpiration.length === 0) {
core.info("No discussions with expiration markers found");
diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs
index 2c4ad14d33..febb8c5483 100644
--- a/actions/setup/js/close_expired_issues.cjs
+++ b/actions/setup/js/close_expired_issues.cjs
@@ -2,7 +2,8 @@
//
const { getErrorMessage } = require("./error_helpers.cjs");
-const { EXPIRATION_PATTERN, extractExpirationDate } = require("./ephemerals.cjs");
+const { extractExpirationDate } = require("./ephemerals.cjs");
+const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
/**
* Maximum number of issues to update per run
@@ -23,105 +24,6 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
-/**
- * Search for open issues with expiration markers
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @returns {Promise<{issues: Array<{id: string, number: number, title: string, url: string, body: string, createdAt: string}>, stats: {pageCount: number, totalScanned: number}}>}
- */
-async function searchIssuesWithExpiration(github, owner, repo) {
- const issues = [];
- let hasNextPage = true;
- let cursor = null;
- let pageCount = 0;
- let totalScanned = 0;
-
- core.info(`Starting GraphQL search for open issues in ${owner}/${repo}`);
-
- while (hasNextPage) {
- pageCount++;
- core.info(`Fetching page ${pageCount} of open issues (cursor: ${cursor || "initial"})`);
-
- const query = `
- query($owner: String!, $repo: String!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- issues(first: 100, after: $cursor, states: [OPEN]) {
- pageInfo {
- hasNextPage
- endCursor
- }
- nodes {
- id
- number
- title
- url
- body
- createdAt
- }
- }
- }
- }
- `;
-
- const result = await github.graphql(query, {
- owner: owner,
- repo: repo,
- cursor: cursor,
- });
-
- if (!result || !result.repository || !result.repository.issues) {
- core.warning(`GraphQL query returned no data at page ${pageCount}`);
- break;
- }
-
- const nodes = result.repository.issues.nodes || [];
- totalScanned += nodes.length;
- core.info(`Page ${pageCount}: Retrieved ${nodes.length} open issues (total scanned: ${totalScanned})`);
-
- let agenticCount = 0;
- let withExpirationCount = 0;
-
- // Filter for issues with agentic workflow markers and expiration comments
- for (const issue of nodes) {
- // Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
- const agenticPattern = /^> AI generated by/m;
- const isAgenticWorkflow = issue.body && agenticPattern.test(issue.body);
-
- if (isAgenticWorkflow) {
- agenticCount++;
- }
-
- if (!isAgenticWorkflow) {
- continue;
- }
-
- // Check if has expiration marker with checked checkbox
- const match = issue.body ? issue.body.match(EXPIRATION_PATTERN) : null;
-
- if (match) {
- withExpirationCount++;
- core.info(` Found issue #${issue.number} with expiration marker: "${match[1]}" - ${issue.title}`);
- issues.push(issue);
- }
- }
-
- core.info(`Page ${pageCount} summary: ${agenticCount} agentic issues, ${withExpirationCount} with expiration markers`);
-
- hasNextPage = result.repository.issues.pageInfo.hasNextPage;
- cursor = result.repository.issues.pageInfo.endCursor;
- }
-
- core.info(`Search complete: Scanned ${totalScanned} issues across ${pageCount} pages, found ${issues.length} with expiration markers`);
- return {
- issues,
- stats: {
- pageCount,
- totalScanned,
- },
- };
-}
-
/**
* Validate issue creation date
* @param {string} createdAt - ISO 8601 creation date
@@ -179,7 +81,11 @@ async function main() {
core.info(`Searching for expired issues in ${owner}/${repo}`);
// Search for issues with expiration markers
- const { issues: issuesWithExpiration, stats: searchStats } = await searchIssuesWithExpiration(github, owner, repo);
+ const { items: issuesWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
if (issuesWithExpiration.length === 0) {
core.info("No issues with expiration markers found");
diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs
index 6aceaf8ece..a5858cc369 100644
--- a/actions/setup/js/close_expired_pull_requests.cjs
+++ b/actions/setup/js/close_expired_pull_requests.cjs
@@ -2,7 +2,8 @@
//
const { getErrorMessage } = require("./error_helpers.cjs");
-const { EXPIRATION_PATTERN, extractExpirationDate } = require("./ephemerals.cjs");
+const { extractExpirationDate } = require("./ephemerals.cjs");
+const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
/**
* Maximum number of pull requests to update per run
@@ -23,105 +24,6 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
-/**
- * Search for open pull requests with expiration markers
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @returns {Promise<{pullRequests: Array<{id: string, number: number, title: string, url: string, body: string, createdAt: string}>, stats: {pageCount: number, totalScanned: number}}>}
- */
-async function searchPullRequestsWithExpiration(github, owner, repo) {
- const pullRequests = [];
- let hasNextPage = true;
- let cursor = null;
- let pageCount = 0;
- let totalScanned = 0;
-
- core.info(`Starting GraphQL search for open pull requests in ${owner}/${repo}`);
-
- while (hasNextPage) {
- pageCount++;
- core.info(`Fetching page ${pageCount} of open pull requests (cursor: ${cursor || "initial"})`);
-
- const query = `
- query($owner: String!, $repo: String!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- pullRequests(first: 100, after: $cursor, states: [OPEN]) {
- pageInfo {
- hasNextPage
- endCursor
- }
- nodes {
- id
- number
- title
- url
- body
- createdAt
- }
- }
- }
- }
- `;
-
- const result = await github.graphql(query, {
- owner: owner,
- repo: repo,
- cursor: cursor,
- });
-
- if (!result || !result.repository || !result.repository.pullRequests) {
- core.warning(`GraphQL query returned no data at page ${pageCount}`);
- break;
- }
-
- const nodes = result.repository.pullRequests.nodes || [];
- totalScanned += nodes.length;
- core.info(`Page ${pageCount}: Retrieved ${nodes.length} open pull requests (total scanned: ${totalScanned})`);
-
- let agenticCount = 0;
- let withExpirationCount = 0;
-
- // Filter for pull requests with agentic workflow markers and expiration comments
- for (const pr of nodes) {
- // Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
- const agenticPattern = /^> AI generated by/m;
- const isAgenticWorkflow = pr.body && agenticPattern.test(pr.body);
-
- if (isAgenticWorkflow) {
- agenticCount++;
- }
-
- if (!isAgenticWorkflow) {
- continue;
- }
-
- // Check if has expiration marker with checked checkbox
- const match = pr.body ? pr.body.match(EXPIRATION_PATTERN) : null;
-
- if (match) {
- withExpirationCount++;
- core.info(` Found pull request #${pr.number} with expiration marker: "${match[1]}" - ${pr.title}`);
- pullRequests.push(pr);
- }
- }
-
- core.info(`Page ${pageCount} summary: ${agenticCount} agentic pull requests, ${withExpirationCount} with expiration markers`);
-
- hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage;
- cursor = result.repository.pullRequests.pageInfo.endCursor;
- }
-
- core.info(`Search complete: Scanned ${totalScanned} pull requests across ${pageCount} pages, found ${pullRequests.length} with expiration markers`);
- return {
- pullRequests,
- stats: {
- pageCount,
- totalScanned,
- },
- };
-}
-
/**
* Validate pull request creation date
* @param {string} createdAt - ISO 8601 creation date
@@ -178,7 +80,11 @@ async function main() {
core.info(`Searching for expired pull requests in ${owner}/${repo}`);
// Search for pull requests with expiration markers
- const { pullRequests: pullRequestsWithExpiration, stats: searchStats } = await searchPullRequestsWithExpiration(github, owner, repo);
+ const { items: pullRequestsWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, {
+ entityType: "pull requests",
+ graphqlField: "pullRequests",
+ resultKey: "pullRequests",
+ });
if (pullRequestsWithExpiration.length === 0) {
core.info("No pull requests with expiration markers found");
diff --git a/actions/setup/js/expired_entity_search_helpers.cjs b/actions/setup/js/expired_entity_search_helpers.cjs
new file mode 100644
index 0000000000..85637129a1
--- /dev/null
+++ b/actions/setup/js/expired_entity_search_helpers.cjs
@@ -0,0 +1,160 @@
+// @ts-check
+///
+
+const { EXPIRATION_PATTERN } = require("./ephemerals.cjs");
+
+/**
+ * Configuration for entity-specific GraphQL search
+ * @typedef {Object} EntitySearchConfig
+ * @property {string} entityType - Entity type name for logging (e.g., "issues", "pull requests", "discussions")
+ * @property {string} graphqlField - GraphQL field name (e.g., "issues", "pullRequests", "discussions")
+ * @property {string} resultKey - Key to use in return object (e.g., "issues", "pullRequests", "discussions")
+ * @property {boolean} [enableDedupe] - Enable duplicate ID tracking (default: false)
+ */
+
+/**
+ * Search statistics
+ * @typedef {Object} SearchStats
+ * @property {number} pageCount - Number of pages fetched
+ * @property {number} totalScanned - Total number of entities scanned
+ * @property {number} [duplicateCount] - Number of duplicates found (only if dedupe enabled)
+ */
+
+/**
+ * Search result containing entities and statistics
+ * @typedef {Object} SearchResult
+ * @property {Array<{id: string, number: number, title: string, url: string, body: string, createdAt: string}>} items - Array of entities with expiration markers
+ * @property {SearchStats} stats - Search statistics
+ */
+
+/**
+ * Search for open entities with expiration markers using GraphQL pagination
+ *
+ * This function provides a generic implementation for searching GitHub entities
+ * (issues, pull requests, discussions) that have:
+ * 1. Agentic workflow markers (body contains "> AI generated by")
+ * 2. Expiration markers (checkbox with expiration date)
+ *
+ * @param {any} github - GitHub GraphQL instance
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {EntitySearchConfig} config - Entity-specific configuration
+ * @returns {Promise} Search results with items and statistics
+ */
+async function searchEntitiesWithExpiration(github, owner, repo, config) {
+ const items = [];
+ const seenIds = config.enableDedupe ? new Set() : null;
+ let hasNextPage = true;
+ let cursor = null;
+ let pageCount = 0;
+ let totalScanned = 0;
+ let duplicateCount = 0;
+
+ core.info(`Starting GraphQL search for open ${config.entityType} in ${owner}/${repo}`);
+
+ while (hasNextPage) {
+ pageCount++;
+ core.info(`Fetching page ${pageCount} of open ${config.entityType} (cursor: ${cursor || "initial"})`);
+
+ const query = `
+ query($owner: String!, $repo: String!, $cursor: String) {
+ repository(owner: $owner, name: $repo) {
+ ${config.graphqlField}(first: 100, after: $cursor, states: [OPEN]) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ number
+ title
+ url
+ body
+ createdAt
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await github.graphql(query, {
+ owner: owner,
+ repo: repo,
+ cursor: cursor,
+ });
+
+ if (!result || !result.repository || !result.repository[config.graphqlField]) {
+ core.warning(`GraphQL query returned no data at page ${pageCount}`);
+ break;
+ }
+
+ const nodes = result.repository[config.graphqlField].nodes || [];
+ totalScanned += nodes.length;
+ core.info(`Page ${pageCount}: Retrieved ${nodes.length} open ${config.entityType} (total scanned: ${totalScanned})`);
+
+ let agenticCount = 0;
+ let withExpirationCount = 0;
+
+ // Filter for entities with agentic workflow markers and expiration comments
+ for (const entity of nodes) {
+ // Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
+ const agenticPattern = /^> AI generated by/m;
+ const isAgenticWorkflow = entity.body && agenticPattern.test(entity.body);
+
+ if (isAgenticWorkflow) {
+ agenticCount++;
+ }
+
+ if (!isAgenticWorkflow) {
+ continue;
+ }
+
+ // Check if has expiration marker with checked checkbox
+ const match = entity.body ? entity.body.match(EXPIRATION_PATTERN) : null;
+
+ if (match) {
+ withExpirationCount++;
+
+ // Deduplicate if enabled (discussions may have duplicates across pages)
+ if (seenIds) {
+ if (seenIds.has(entity.id)) {
+ core.warning(` Skipping duplicate ${config.entityType.slice(0, -1)} #${entity.number} (ID: ${entity.id}) - already seen in previous page`);
+ duplicateCount++;
+ continue;
+ }
+ seenIds.add(entity.id);
+ }
+
+ core.info(` Found ${config.entityType.slice(0, -1)} #${entity.number} with expiration marker: "${match[1]}" - ${entity.title}`);
+ items.push(entity);
+ }
+ }
+
+ core.info(`Page ${pageCount} summary: ${agenticCount} agentic ${config.entityType}, ${withExpirationCount} with expiration markers`);
+
+ hasNextPage = result.repository[config.graphqlField].pageInfo.hasNextPage;
+ cursor = result.repository[config.graphqlField].pageInfo.endCursor;
+ }
+
+ if (config.enableDedupe && duplicateCount > 0) {
+ core.warning(`Found and skipped ${duplicateCount} duplicate ${config.entityType} across pages`);
+ }
+
+ const uniqueQualifier = config.enableDedupe ? " unique" : "";
+ core.info(`Search complete: Scanned ${totalScanned} ${config.entityType} across ${pageCount} pages, found ${items.length}${uniqueQualifier} with expiration markers`);
+
+ const stats = {
+ pageCount,
+ totalScanned,
+ };
+
+ if (config.enableDedupe) {
+ stats.duplicateCount = duplicateCount;
+ }
+
+ return { items, stats };
+}
+
+module.exports = {
+ searchEntitiesWithExpiration,
+};
diff --git a/actions/setup/js/expired_entity_search_helpers.test.cjs b/actions/setup/js/expired_entity_search_helpers.test.cjs
new file mode 100644
index 0000000000..9aaf2f72a8
--- /dev/null
+++ b/actions/setup/js/expired_entity_search_helpers.test.cjs
@@ -0,0 +1,429 @@
+// @ts-check
+///
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mock the core module
+global.core = {
+ info: vi.fn(),
+ warning: vi.fn(),
+};
+
+const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
+
+describe("searchEntitiesWithExpiration", () => {
+ let mockGithub;
+ const owner = "test-owner";
+ const repo = "test-repo";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should search for issues with expiration markers", async () => {
+ const mockIssue = {
+ id: "issue-1",
+ number: 123,
+ title: "Test Issue",
+ url: "https://github.com/test-owner/test-repo/issues/123",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [mockIssue],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toEqual(mockIssue);
+ expect(result.stats.pageCount).toBe(1);
+ expect(result.stats.totalScanned).toBe(1);
+ });
+
+ it("should search for pull requests with expiration markers", async () => {
+ const mockPR = {
+ id: "pr-1",
+ number: 456,
+ title: "Test PR",
+ url: "https://github.com/test-owner/test-repo/pull/456",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ pullRequests: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [mockPR],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "pull requests",
+ graphqlField: "pullRequests",
+ resultKey: "pullRequests",
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toEqual(mockPR);
+ expect(result.stats.pageCount).toBe(1);
+ expect(result.stats.totalScanned).toBe(1);
+ });
+
+ it("should search for discussions with expiration markers", async () => {
+ const mockDiscussion = {
+ id: "discussion-1",
+ number: 789,
+ title: "Test Discussion",
+ url: "https://github.com/test-owner/test-repo/discussions/789",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ discussions: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [mockDiscussion],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "discussions",
+ graphqlField: "discussions",
+ resultKey: "discussions",
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toEqual(mockDiscussion);
+ expect(result.stats.pageCount).toBe(1);
+ expect(result.stats.totalScanned).toBe(1);
+ });
+
+ it("should filter out entities without agentic workflow markers", async () => {
+ const agenticIssue = {
+ id: "issue-1",
+ number: 1,
+ title: "Agentic Issue",
+ url: "https://github.com/test-owner/test-repo/issues/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ const manualIssue = {
+ id: "issue-2",
+ number: 2,
+ title: "Manual Issue",
+ url: "https://github.com/test-owner/test-repo/issues/2",
+ body: "Regular issue without agentic marker\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [agenticIssue, manualIssue],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toEqual(agenticIssue);
+ expect(result.stats.totalScanned).toBe(2);
+ });
+
+ it("should filter out entities without expiration markers", async () => {
+ const withExpiration = {
+ id: "issue-1",
+ number: 1,
+ title: "Issue with expiration",
+ url: "https://github.com/test-owner/test-repo/issues/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ const withoutExpiration = {
+ id: "issue-2",
+ number: 2,
+ title: "Issue without expiration",
+ url: "https://github.com/test-owner/test-repo/issues/2",
+ body: "> AI generated by workflow\n\nNo expiration marker",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [withExpiration, withoutExpiration],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toEqual(withExpiration);
+ expect(result.stats.totalScanned).toBe(2);
+ });
+
+ it("should handle pagination correctly", async () => {
+ const page1Issue = {
+ id: "issue-1",
+ number: 1,
+ title: "Issue 1",
+ url: "https://github.com/test-owner/test-repo/issues/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ const page2Issue = {
+ id: "issue-2",
+ number: 2,
+ title: "Issue 2",
+ url: "https://github.com/test-owner/test-repo/issues/2",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi
+ .fn()
+ .mockResolvedValueOnce({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
+ nodes: [page1Issue],
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [page2Issue],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(2);
+ expect(result.stats.pageCount).toBe(2);
+ expect(result.stats.totalScanned).toBe(2);
+ expect(mockGithub.graphql).toHaveBeenCalledTimes(2);
+ });
+
+ it("should deduplicate entities when enableDedupe is true", async () => {
+ const discussion = {
+ id: "discussion-1",
+ number: 1,
+ title: "Duplicate Discussion",
+ url: "https://github.com/test-owner/test-repo/discussions/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ // Same discussion appears on two pages (duplicate)
+ mockGithub = {
+ graphql: vi
+ .fn()
+ .mockResolvedValueOnce({
+ repository: {
+ discussions: {
+ pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
+ nodes: [discussion],
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ repository: {
+ discussions: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [discussion], // Same discussion again
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "discussions",
+ graphqlField: "discussions",
+ resultKey: "discussions",
+ enableDedupe: true,
+ });
+
+ expect(result.items).toHaveLength(1); // Only one, not two
+ expect(result.stats.pageCount).toBe(2);
+ expect(result.stats.totalScanned).toBe(2);
+ expect(result.stats.duplicateCount).toBe(1);
+ });
+
+ it("should not deduplicate when enableDedupe is false", async () => {
+ const issue1 = {
+ id: "issue-1",
+ number: 1,
+ title: "Issue 1",
+ url: "https://github.com/test-owner/test-repo/issues/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ const issue2 = {
+ id: "issue-1", // Same ID (unlikely in practice for issues, but testing the logic)
+ number: 1,
+ title: "Issue 1",
+ url: "https://github.com/test-owner/test-repo/issues/1",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi
+ .fn()
+ .mockResolvedValueOnce({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
+ nodes: [issue1],
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [issue2],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ enableDedupe: false, // Deduplication disabled
+ });
+
+ expect(result.items).toHaveLength(2); // Both included
+ expect(result.stats.pageCount).toBe(2);
+ expect(result.stats.totalScanned).toBe(2);
+ expect(result.stats.duplicateCount).toBeUndefined();
+ });
+
+ it("should handle empty results", async () => {
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [],
+ },
+ },
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(0);
+ expect(result.stats.pageCount).toBe(1);
+ expect(result.stats.totalScanned).toBe(0);
+ });
+
+ it("should handle null or missing GraphQL data gracefully", async () => {
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: null,
+ }),
+ };
+
+ const result = await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(result.items).toHaveLength(0);
+ expect(result.stats.pageCount).toBe(1);
+ expect(result.stats.totalScanned).toBe(0);
+ expect(global.core.warning).toHaveBeenCalledWith("GraphQL query returned no data at page 1");
+ });
+
+ it("should log appropriate messages during search", async () => {
+ const mockIssue = {
+ id: "issue-1",
+ number: 123,
+ title: "Test Issue",
+ url: "https://github.com/test-owner/test-repo/issues/123",
+ body: "> AI generated by workflow\n\n> - [x] expires ",
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+
+ mockGithub = {
+ graphql: vi.fn().mockResolvedValue({
+ repository: {
+ issues: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [mockIssue],
+ },
+ },
+ }),
+ };
+
+ await searchEntitiesWithExpiration(mockGithub, owner, repo, {
+ entityType: "issues",
+ graphqlField: "issues",
+ resultKey: "issues",
+ });
+
+ expect(global.core.info).toHaveBeenCalledWith("Starting GraphQL search for open issues in test-owner/test-repo");
+ expect(global.core.info).toHaveBeenCalledWith("Fetching page 1 of open issues (cursor: initial)");
+ expect(global.core.info).toHaveBeenCalledWith("Page 1: Retrieved 1 open issues (total scanned: 1)");
+ expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining("Found issue #123 with expiration marker:"));
+ expect(global.core.info).toHaveBeenCalledWith("Search complete: Scanned 1 issues across 1 pages, found 1 with expiration markers");
+ });
+});