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