From 9e295d33ad8dea2f6e49242af6d65a85e23d8e9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:05:00 +0000 Subject: [PATCH 1/2] Initial plan From 87fba1cec864cd01c6bbfcacefb9e30b3d56e7a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:15:47 +0000 Subject: [PATCH 2/2] Refactor: Extract shared missing issue handler logic into missing_issue_helpers.cjs - Create missing_issue_helpers.cjs with buildMissingIssueHandler() factory - Refactor create_missing_data_issue.cjs to use shared helper - Refactor create_missing_tool_issue.cjs to use shared helper - Add missing_issue_helpers.test.cjs with 14 tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/create_missing_data_issue.cjs | 232 ++------------ .../setup/js/create_missing_tool_issue.cjs | 224 ++------------ actions/setup/js/missing_issue_helpers.cjs | 219 +++++++++++++ .../setup/js/missing_issue_helpers.test.cjs | 287 ++++++++++++++++++ 4 files changed, 550 insertions(+), 412 deletions(-) create mode 100644 actions/setup/js/missing_issue_helpers.cjs create mode 100644 actions/setup/js/missing_issue_helpers.test.cjs diff --git a/actions/setup/js/create_missing_data_issue.cjs b/actions/setup/js/create_missing_data_issue.cjs index d40de3922bd..b04fa7bad63 100644 --- a/actions/setup/js/create_missing_data_issue.cjs +++ b/actions/setup/js/create_missing_data_issue.cjs @@ -1,11 +1,7 @@ // @ts-check /// -const { getErrorMessage } = require("./error_helpers.cjs"); -const { renderTemplate } = require("./messages_core.cjs"); -const { createExpirationLine, generateFooterWithExpiration } = require("./ephemerals.cjs"); -const fs = require("fs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); +const { buildMissingIssueHandler } = require("./missing_issue_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -19,209 +15,27 @@ const HANDLER_TYPE = "create_missing_data_issue"; * Returns a message handler function that processes individual create_missing_data_issue messages * @type {HandlerFactoryFunction} */ -async function main(config = {}) { - // Extract configuration - const titlePrefix = config.title_prefix || "[missing data]"; - const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : []; - const maxCount = config.max || 1; // Default to 1 to create only one issue per workflow run - - core.info(`Title prefix: ${titlePrefix}`); - if (envLabels.length > 0) { - core.info(`Default labels: ${envLabels.join(", ")}`); - } - core.info(`Max count: ${maxCount}`); - - // Track how many items we've processed for max limit - let processedCount = 0; - - // Track created/updated issues - const processedIssues = []; - - /** - * Create or update an issue for missing data - * @param {string} workflowName - Name of the workflow - * @param {string} workflowSource - Source path of the workflow - * @param {string} workflowSourceURL - URL to the workflow source - * @param {string} runUrl - URL to the workflow run - * @param {Array} missingDataItems - Array of missing data objects - * @returns {Promise} Result with success/error status - */ - async function createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, missingDataItems) { - const { owner, repo } = context.repo; - - // Create issue title - const issueTitle = `${titlePrefix} ${workflowName}`; - - core.info(`Checking for existing issue with title: "${issueTitle}"`); - - // Search for existing open issue with this title - const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${issueTitle}"`; - - try { - const searchResult = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - if (searchResult.data.total_count > 0) { - // Issue exists, add a comment - const existingIssue = searchResult.data.items[0]; - core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); - - // Build comment body - const commentLines = [`## Missing Data Reported`, ``, `The following data was reported as missing during [workflow run](${runUrl}):`, ``]; - - missingDataItems.forEach((item, index) => { - commentLines.push(`### ${index + 1}. **${item.data_type}**`); - commentLines.push(`**Reason:** ${item.reason}`); - if (item.context) { - commentLines.push(`**Context:** ${item.context}`); - } - if (item.alternatives) { - commentLines.push(`**Alternatives:** ${item.alternatives}`); - } - commentLines.push(``); - }); - - commentLines.push(`---`); - commentLines.push(`> Workflow: [${workflowName}](${workflowSourceURL})`); - commentLines.push(`> Run: ${runUrl}`); - - const commentBody = sanitizeContent(commentLines.join("\n")); - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: existingIssue.number, - body: commentBody, - }); - - core.info(`✓ Added comment to existing issue #${existingIssue.number}`); - - return { - success: true, - issue_number: existingIssue.number, - issue_url: existingIssue.html_url, - action: "updated", - }; - } else { - // No existing issue, create a new one - core.info("No existing issue found, creating a new one"); - - // Load issue template - const issueTemplatePath = "/opt/gh-aw/prompts/missing_data_issue.md"; - const issueTemplate = fs.readFileSync(issueTemplatePath, "utf8"); - - // Build missing data list for template - const missingDataListLines = []; - missingDataItems.forEach((item, index) => { - missingDataListLines.push(`#### ${index + 1}. **${item.data_type}**`); - missingDataListLines.push(`**Reason:** ${item.reason}`); - if (item.context) { - missingDataListLines.push(`**Context:** ${item.context}`); - } - if (item.alternatives) { - missingDataListLines.push(`**Alternatives:** ${item.alternatives}`); - } - missingDataListLines.push(`**Reported at:** ${item.timestamp}`); - missingDataListLines.push(``); - }); - - // Create template context - const templateContext = { - workflow_name: workflowName, - workflow_source_url: workflowSourceURL || "#", - run_url: runUrl, - workflow_source: workflowSource, - missing_data_list: missingDataListLines.join("\n"), - }; - - // Render the issue template - const issueBodyContent = renderTemplate(issueTemplate, templateContext); - - // Add expiration marker (1 week from now) in a quoted section using helper - const footer = generateFooterWithExpiration({ - footerText: `> Workflow: [${workflowName}](${workflowSourceURL})`, - expiresHours: 24 * 7, // 7 days - }); - const issueBody = sanitizeContent(`${issueBodyContent}\n\n${footer}`); - - const newIssue = await github.rest.issues.create({ - owner, - repo, - title: issueTitle, - body: issueBody, - labels: envLabels, - }); - - core.info(`✓ Created new issue #${newIssue.data.number}: ${newIssue.data.html_url}`); - - return { - success: true, - issue_number: newIssue.data.number, - issue_url: newIssue.data.html_url, - action: "created", - }; - } - } catch (error) { - core.warning(`Failed to create or update issue: ${getErrorMessage(error)}`); - return { - success: false, - error: getErrorMessage(error), - }; - } - } - - /** - * Message handler function that processes a single create_missing_data_issue message - * @param {Object} message - The create_missing_data_issue message to process - * @returns {Promise} Result with success/error status and issue details - */ - return async function handleCreateMissingDataIssue(message) { - // Check if we've hit the max limit - if (processedCount >= maxCount) { - core.warning(`Skipping create_missing_data_issue: max count of ${maxCount} reached`); - return { - success: false, - error: `Max count of ${maxCount} reached`, - }; - } - - processedCount++; - - // Validate required fields - if (!message.workflow_name) { - core.warning(`Missing required field: workflow_name`); - return { - success: false, - error: "Missing required field: workflow_name", - }; - } - - if (!message.missing_data || !Array.isArray(message.missing_data) || message.missing_data.length === 0) { - core.warning(`Missing or empty missing_data array`); - return { - success: false, - error: "Missing or empty missing_data array", - }; - } - - // Extract fields from message - const workflowName = message.workflow_name; - const workflowSource = message.workflow_source || ""; - const workflowSourceURL = message.workflow_source_url || ""; - const runUrl = message.run_url || ""; - const missingDataItems = message.missing_data; - - // Create or update the issue - const result = await createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, missingDataItems); - - if (result.success) { - processedIssues.push(result); - } - - return result; - }; -} +const main = buildMissingIssueHandler({ + handlerType: HANDLER_TYPE, + defaultTitlePrefix: "[missing data]", + itemsField: "missing_data", + templatePath: "/opt/gh-aw/prompts/missing_data_issue.md", + templateListKey: "missing_data_list", + buildCommentHeader: runUrl => [`## Missing Data Reported`, ``, `The following data was reported as missing during [workflow run](${runUrl}):`, ``], + renderCommentItem: (item, index) => { + const lines = [`### ${index + 1}. **${item.data_type}**`, `**Reason:** ${item.reason}`]; + if (item.context) lines.push(`**Context:** ${item.context}`); + if (item.alternatives) lines.push(`**Alternatives:** ${item.alternatives}`); + lines.push(``); + return lines; + }, + renderIssueItem: (item, index) => { + const lines = [`#### ${index + 1}. **${item.data_type}**`, `**Reason:** ${item.reason}`]; + if (item.context) lines.push(`**Context:** ${item.context}`); + if (item.alternatives) lines.push(`**Alternatives:** ${item.alternatives}`); + lines.push(`**Reported at:** ${item.timestamp}`, ``); + return lines; + }, +}); module.exports = { main }; diff --git a/actions/setup/js/create_missing_tool_issue.cjs b/actions/setup/js/create_missing_tool_issue.cjs index 442a6a71062..462dab32e01 100644 --- a/actions/setup/js/create_missing_tool_issue.cjs +++ b/actions/setup/js/create_missing_tool_issue.cjs @@ -1,11 +1,7 @@ // @ts-check /// -const { getErrorMessage } = require("./error_helpers.cjs"); -const { renderTemplate } = require("./messages_core.cjs"); -const { createExpirationLine, generateFooterWithExpiration } = require("./ephemerals.cjs"); -const fs = require("fs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); +const { buildMissingIssueHandler } = require("./missing_issue_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -19,203 +15,25 @@ const HANDLER_TYPE = "create_missing_tool_issue"; * Returns a message handler function that processes individual create_missing_tool_issue messages * @type {HandlerFactoryFunction} */ -async function main(config = {}) { - // Extract configuration - const titlePrefix = config.title_prefix || "[missing tool]"; - const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : []; - const maxCount = config.max || 1; // Default to 1 to create only one issue per workflow run - - core.info(`Title prefix: ${titlePrefix}`); - if (envLabels.length > 0) { - core.info(`Default labels: ${envLabels.join(", ")}`); - } - core.info(`Max count: ${maxCount}`); - - // Track how many items we've processed for max limit - let processedCount = 0; - - // Track created/updated issues - const processedIssues = []; - - /** - * Create or update an issue for missing tools - * @param {string} workflowName - Name of the workflow - * @param {string} workflowSource - Source path of the workflow - * @param {string} workflowSourceURL - URL to the workflow source - * @param {string} runUrl - URL to the workflow run - * @param {Array} missingTools - Array of missing tool objects - * @returns {Promise} Result with success/error status - */ - async function createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, missingTools) { - const { owner, repo } = context.repo; - - // Create issue title - const issueTitle = `${titlePrefix} ${workflowName}`; - - core.info(`Checking for existing issue with title: "${issueTitle}"`); - - // Search for existing open issue with this title - const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${issueTitle}"`; - - try { - const searchResult = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - if (searchResult.data.total_count > 0) { - // Issue exists, add a comment - const existingIssue = searchResult.data.items[0]; - core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); - - // Build comment body - const commentLines = [`## Missing Tools Reported`, ``, `The following tools were reported as missing during [workflow run](${runUrl}):`, ``]; - - missingTools.forEach((tool, index) => { - commentLines.push(`### ${index + 1}. \`${tool.tool}\``); - commentLines.push(`**Reason:** ${tool.reason}`); - if (tool.alternatives) { - commentLines.push(`**Alternatives:** ${tool.alternatives}`); - } - commentLines.push(``); - }); - - commentLines.push(`---`); - commentLines.push(`> Workflow: [${workflowName}](${workflowSourceURL})`); - commentLines.push(`> Run: ${runUrl}`); - - const commentBody = sanitizeContent(commentLines.join("\n")); - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: existingIssue.number, - body: commentBody, - }); - - core.info(`✓ Added comment to existing issue #${existingIssue.number}`); - - return { - success: true, - issue_number: existingIssue.number, - issue_url: existingIssue.html_url, - action: "updated", - }; - } else { - // No existing issue, create a new one - core.info("No existing issue found, creating a new one"); - - // Load issue template - const issueTemplatePath = "/opt/gh-aw/prompts/missing_tool_issue.md"; - const issueTemplate = fs.readFileSync(issueTemplatePath, "utf8"); - - // Build missing tools list for template - const missingToolsListLines = []; - missingTools.forEach((tool, index) => { - missingToolsListLines.push(`#### ${index + 1}. \`${tool.tool}\``); - missingToolsListLines.push(`**Reason:** ${tool.reason}`); - if (tool.alternatives) { - missingToolsListLines.push(`**Alternatives:** ${tool.alternatives}`); - } - missingToolsListLines.push(`**Reported at:** ${tool.timestamp}`); - missingToolsListLines.push(``); - }); - - // Create template context - const templateContext = { - workflow_name: workflowName, - workflow_source_url: workflowSourceURL || "#", - run_url: runUrl, - workflow_source: workflowSource, - missing_tools_list: missingToolsListLines.join("\n"), - }; - - // Render the issue template - const issueBodyContent = renderTemplate(issueTemplate, templateContext); - - // Add expiration marker (1 week from now) in a quoted section using helper - const footer = generateFooterWithExpiration({ - footerText: `> Workflow: [${workflowName}](${workflowSourceURL})`, - expiresHours: 24 * 7, // 7 days - }); - const issueBody = sanitizeContent(`${issueBodyContent}\n\n${footer}`); - - const newIssue = await github.rest.issues.create({ - owner, - repo, - title: issueTitle, - body: issueBody, - labels: envLabels, - }); - - core.info(`✓ Created new issue #${newIssue.data.number}: ${newIssue.data.html_url}`); - - return { - success: true, - issue_number: newIssue.data.number, - issue_url: newIssue.data.html_url, - action: "created", - }; - } - } catch (error) { - core.warning(`Failed to create or update issue: ${getErrorMessage(error)}`); - return { - success: false, - error: getErrorMessage(error), - }; - } - } - - /** - * Message handler function that processes a single create_missing_tool_issue message - * @param {Object} message - The create_missing_tool_issue message to process - * @returns {Promise} Result with success/error status and issue details - */ - return async function handleCreateMissingToolIssue(message) { - // Check if we've hit the max limit - if (processedCount >= maxCount) { - core.warning(`Skipping create_missing_tool_issue: max count of ${maxCount} reached`); - return { - success: false, - error: `Max count of ${maxCount} reached`, - }; - } - - processedCount++; - - // Validate required fields - if (!message.workflow_name) { - core.warning(`Missing required field: workflow_name`); - return { - success: false, - error: "Missing required field: workflow_name", - }; - } - - if (!message.missing_tools || !Array.isArray(message.missing_tools) || message.missing_tools.length === 0) { - core.warning(`Missing or empty missing_tools array`); - return { - success: false, - error: "Missing or empty missing_tools array", - }; - } - - // Extract fields from message - const workflowName = message.workflow_name; - const workflowSource = message.workflow_source || ""; - const workflowSourceURL = message.workflow_source_url || ""; - const runUrl = message.run_url || ""; - const missingTools = message.missing_tools; - - // Create or update the issue - const result = await createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, missingTools); - - if (result.success) { - processedIssues.push(result); - } - - return result; - }; -} +const main = buildMissingIssueHandler({ + handlerType: HANDLER_TYPE, + defaultTitlePrefix: "[missing tool]", + itemsField: "missing_tools", + templatePath: "/opt/gh-aw/prompts/missing_tool_issue.md", + templateListKey: "missing_tools_list", + buildCommentHeader: runUrl => [`## Missing Tools Reported`, ``, `The following tools were reported as missing during [workflow run](${runUrl}):`, ``], + renderCommentItem: (tool, index) => { + const lines = [`### ${index + 1}. \`${tool.tool}\``, `**Reason:** ${tool.reason}`]; + if (tool.alternatives) lines.push(`**Alternatives:** ${tool.alternatives}`); + lines.push(``); + return lines; + }, + renderIssueItem: (tool, index) => { + const lines = [`#### ${index + 1}. \`${tool.tool}\``, `**Reason:** ${tool.reason}`]; + if (tool.alternatives) lines.push(`**Alternatives:** ${tool.alternatives}`); + lines.push(`**Reported at:** ${tool.timestamp}`, ``); + return lines; + }, +}); module.exports = { main }; diff --git a/actions/setup/js/missing_issue_helpers.cjs b/actions/setup/js/missing_issue_helpers.cjs new file mode 100644 index 00000000000..81c548cedc4 --- /dev/null +++ b/actions/setup/js/missing_issue_helpers.cjs @@ -0,0 +1,219 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { renderTemplate } = require("./messages_core.cjs"); +const { generateFooterWithExpiration } = require("./ephemerals.cjs"); +const fs = require("fs"); +const { sanitizeContent } = require("./sanitize_content.cjs"); + +/** + * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction + */ + +/** + * Build a shared handler factory for missing issue handlers. + * Encapsulates the common search-or-create issue pipeline, differing only in + * template paths, item field names, and item renderers. + * + * @param {Object} options + * @param {string} options.handlerType - Handler type identifier used in log/warning messages + * @param {string} options.defaultTitlePrefix - Default issue title prefix (e.g. "[missing data]") + * @param {string} options.itemsField - Field name in the message containing the items array + * @param {string} options.templatePath - Absolute path to the issue body template file + * @param {string} options.templateListKey - Template variable name for the rendered items list + * @param {function(string): string[]} options.buildCommentHeader - Returns header lines for the comment body given runUrl + * @param {function(Object, number): string[]} options.renderCommentItem - Renders a single item for an existing-issue comment + * @param {function(Object, number): string[]} options.renderIssueItem - Renders a single item for a new-issue body + * @returns {HandlerFactoryFunction} + */ +function buildMissingIssueHandler(options) { + const { handlerType, defaultTitlePrefix, itemsField, templatePath, templateListKey, buildCommentHeader, renderCommentItem, renderIssueItem } = options; + + return async function main(config = {}) { + // Extract configuration + const titlePrefix = config.title_prefix || defaultTitlePrefix; + const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : []; + const maxCount = config.max || 1; // Default to 1 to create only one issue per workflow run + + core.info(`Title prefix: ${titlePrefix}`); + if (envLabels.length > 0) { + core.info(`Default labels: ${envLabels.join(", ")}`); + } + core.info(`Max count: ${maxCount}`); + + // Track how many items we've processed for max limit + let processedCount = 0; + + // Track created/updated issues + const processedIssues = []; + + /** + * Create or update an issue for the missing items + * @param {string} workflowName - Name of the workflow + * @param {string} workflowSource - Source path of the workflow + * @param {string} workflowSourceURL - URL to the workflow source + * @param {string} runUrl - URL to the workflow run + * @param {Array} items - Array of missing item objects + * @returns {Promise} Result with success/error status + */ + async function createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, items) { + const { owner, repo } = context.repo; + + // Create issue title + const issueTitle = `${titlePrefix} ${workflowName}`; + + core.info(`Checking for existing issue with title: "${issueTitle}"`); + + // Search for existing open issue with this title + const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${issueTitle}"`; + + try { + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + + if (searchResult.data.total_count > 0) { + // Issue exists, add a comment + const existingIssue = searchResult.data.items[0]; + core.info(`Found existing issue #${existingIssue.number}: ${existingIssue.html_url}`); + + // Build comment body + const commentLines = buildCommentHeader(runUrl); + items.forEach((item, index) => { + commentLines.push(...renderCommentItem(item, index)); + }); + commentLines.push(`---`); + commentLines.push(`> Workflow: [${workflowName}](${workflowSourceURL})`); + commentLines.push(`> Run: ${runUrl}`); + + const commentBody = sanitizeContent(commentLines.join("\n")); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: existingIssue.number, + body: commentBody, + }); + + core.info(`✓ Added comment to existing issue #${existingIssue.number}`); + + return { + success: true, + issue_number: existingIssue.number, + issue_url: existingIssue.html_url, + action: "updated", + }; + } else { + // No existing issue, create a new one + core.info("No existing issue found, creating a new one"); + + // Load issue template + const issueTemplate = fs.readFileSync(templatePath, "utf8"); + + // Build items list for template + const issueListLines = []; + items.forEach((item, index) => { + issueListLines.push(...renderIssueItem(item, index)); + }); + + // Create template context + const templateContext = { + workflow_name: workflowName, + workflow_source_url: workflowSourceURL || "#", + run_url: runUrl, + workflow_source: workflowSource, + [templateListKey]: issueListLines.join("\n"), + }; + + // Render the issue template + const issueBodyContent = renderTemplate(issueTemplate, templateContext); + + // Add expiration marker (1 week from now) in a quoted section using helper + const footer = generateFooterWithExpiration({ + footerText: `> Workflow: [${workflowName}](${workflowSourceURL})`, + expiresHours: 24 * 7, // 7 days + }); + const issueBody = sanitizeContent(`${issueBodyContent}\n\n${footer}`); + + const newIssue = await github.rest.issues.create({ + owner, + repo, + title: issueTitle, + body: issueBody, + labels: envLabels, + }); + + core.info(`✓ Created new issue #${newIssue.data.number}: ${newIssue.data.html_url}`); + + return { + success: true, + issue_number: newIssue.data.number, + issue_url: newIssue.data.html_url, + action: "created", + }; + } + } catch (error) { + core.warning(`Failed to create or update issue: ${getErrorMessage(error)}`); + return { + success: false, + error: getErrorMessage(error), + }; + } + } + + /** + * Message handler function that processes a single missing-issue message + * @param {Object} message - The message to process + * @returns {Promise} Result with success/error status and issue details + */ + return async function handleMissingIssue(message) { + // Check if we've hit the max limit + if (processedCount >= maxCount) { + core.warning(`Skipping ${handlerType}: max count of ${maxCount} reached`); + return { + success: false, + error: `Max count of ${maxCount} reached`, + }; + } + + processedCount++; + + // Validate required fields + if (!message.workflow_name) { + core.warning(`Missing required field: workflow_name`); + return { + success: false, + error: "Missing required field: workflow_name", + }; + } + + if (!message[itemsField] || !Array.isArray(message[itemsField]) || message[itemsField].length === 0) { + core.warning(`Missing or empty ${itemsField} array`); + return { + success: false, + error: `Missing or empty ${itemsField} array`, + }; + } + + // Extract fields from message + const workflowName = message.workflow_name; + const workflowSource = message.workflow_source || ""; + const workflowSourceURL = message.workflow_source_url || ""; + const runUrl = message.run_url || ""; + const items = message[itemsField]; + + // Create or update the issue + const result = await createOrUpdateIssue(workflowName, workflowSource, workflowSourceURL, runUrl, items); + + if (result.success) { + processedIssues.push(result); + } + + return result; + }; + }; +} + +module.exports = { buildMissingIssueHandler }; diff --git a/actions/setup/js/missing_issue_helpers.test.cjs b/actions/setup/js/missing_issue_helpers.test.cjs new file mode 100644 index 00000000000..a6ae5280d30 --- /dev/null +++ b/actions/setup/js/missing_issue_helpers.test.cjs @@ -0,0 +1,287 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const require = createRequire(import.meta.url); + +// Create a temporary template file used by "new issue" tests +const tmpDir = os.tmpdir(); +const testTemplatePath = path.join(tmpDir, "test_missing_issue_template.md"); +fs.writeFileSync(testTemplatePath, "# Missing Items\n\n{{test_list}}\n"); + +// Mock globals before importing the module +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setOutput: vi.fn(), + setFailed: vi.fn(), +}; + +const mockGithub = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + issues: { + create: vi.fn(), + createComment: vi.fn(), + }, + }, +}; + +const mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, +}; + +globalThis.core = mockCore; +globalThis.github = mockGithub; +globalThis.context = mockContext; + +const { buildMissingIssueHandler } = require("./missing_issue_helpers.cjs"); + +/** + * Helper to build a minimal handler options object for testing + * @param {Partial} overrides + */ +function makeOptions(overrides = {}) { + return { + handlerType: "create_test_issue", + defaultTitlePrefix: "[test prefix]", + itemsField: "test_items", + templatePath: testTemplatePath, + templateListKey: "test_list", + buildCommentHeader: runUrl => [`## Test Header`, ``, `Items from [run](${runUrl}):`, ``], + renderCommentItem: (item, index) => [`### ${index + 1}. ${item.name}`, `**Reason:** ${item.reason}`, ``], + renderIssueItem: (item, index) => [`#### ${index + 1}. ${item.name}`, `**Reason:** ${item.reason}`, `**Reported at:** ${item.timestamp}`, ``], + ...overrides, + }; +} + +const defaultMessage = { + workflow_name: "My Workflow", + workflow_source: "my-workflow.md", + workflow_source_url: "https://github.com/owner/repo/blob/main/my-workflow.md", + run_url: "https://github.com/owner/repo/actions/runs/123", + test_items: [{ name: "item-one", reason: "not found", timestamp: "2026-01-01T00:00:00Z" }], +}; + +describe("missing_issue_helpers.cjs - buildMissingIssueHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("validation", () => { + it("should return error when workflow_name is missing", async () => { + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler({ test_items: [{ name: "x", reason: "y" }] }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Missing required field: workflow_name"); + expect(mockCore.warning).toHaveBeenCalledWith("Missing required field: workflow_name"); + }); + + it("should return error when items field is missing", async () => { + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler({ workflow_name: "Test Workflow" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Missing or empty test_items array"); + }); + + it("should return error when items array is empty", async () => { + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler({ workflow_name: "Test Workflow", test_items: [] }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Missing or empty test_items array"); + }); + + it("should return error when items field is not an array", async () => { + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler({ workflow_name: "Test Workflow", test_items: "not-an-array" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Missing or empty test_items array"); + }); + }); + + describe("max count enforcement", () => { + it("should skip processing when max count is reached", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 1, html_url: "https://github.com/owner/repo/issues/1" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions())({ max: 1 }); + + // First call should succeed (or fail with a non-max error) + await handler(defaultMessage); + + // Second call should be rejected due to max count + const result = await handler(defaultMessage); + expect(result.success).toBe(false); + expect(result.error).toBe("Max count of 1 reached"); + expect(mockCore.warning).toHaveBeenCalledWith("Skipping create_test_issue: max count of 1 reached"); + }); + + it("should allow higher max counts", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ number: 42, html_url: "https://github.com/owner/repo/issues/42" }], + }, + }); + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const handler = await buildMissingIssueHandler(makeOptions())({ max: 3 }); + + // First two calls should not hit the limit + await handler(defaultMessage); + await handler(defaultMessage); + + // processedCount is now 2, max is 3 - third should still work + const result = await handler(defaultMessage); + expect(result.success).toBe(true); + }); + }); + + describe("config extraction", () => { + it("should use defaultTitlePrefix when no config.title_prefix provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 10, html_url: "https://github.com/owner/repo/issues/10" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions({ defaultTitlePrefix: "[custom prefix]" }))({}); + await handler(defaultMessage); + + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith(expect.objectContaining({ q: expect.stringContaining("[custom prefix] My Workflow") })); + }); + + it("should use config.title_prefix when provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 10, html_url: "https://github.com/owner/repo/issues/10" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions())({ title_prefix: "[override]" }); + await handler(defaultMessage); + + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith(expect.objectContaining({ q: expect.stringContaining("[override] My Workflow") })); + }); + }); + + describe("existing issue - add comment", () => { + it("should add comment to existing issue and return updated action", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ number: 99, html_url: "https://github.com/owner/repo/issues/99" }], + }, + }); + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler(defaultMessage); + + expect(result.success).toBe(true); + expect(result.action).toBe("updated"); + expect(result.issue_number).toBe(99); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 99, + body: expect.stringContaining("## Test Header"), + }) + ); + }); + + it("should include rendered item in comment body", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ number: 55, html_url: "https://github.com/owner/repo/issues/55" }], + }, + }); + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const handler = await buildMissingIssueHandler(makeOptions())({}); + await handler(defaultMessage); + + const commentBody = mockGithub.rest.issues.createComment.mock.calls[0][0].body; + expect(commentBody).toContain("### 1. item-one"); + expect(commentBody).toContain("**Reason:** not found"); + }); + }); + + describe("new issue - create issue", () => { + it("should create a new issue when no existing issue found", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 77, html_url: "https://github.com/owner/repo/issues/77" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler(defaultMessage); + + expect(result.success).toBe(true); + expect(result.action).toBe("created"); + expect(result.issue_number).toBe(77); + expect(mockGithub.rest.issues.create).toHaveBeenCalled(); + }); + + it("should apply labels when configured", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 77, html_url: "https://github.com/owner/repo/issues/77" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions())({ labels: "bug,help wanted" }); + await handler(defaultMessage); + + expect(mockGithub.rest.issues.create).toHaveBeenCalledWith(expect.objectContaining({ labels: ["bug", "help wanted"] })); + }); + + it("should apply array labels when configured", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + mockGithub.rest.issues.create.mockResolvedValue({ + data: { number: 77, html_url: "https://github.com/owner/repo/issues/77" }, + }); + + const handler = await buildMissingIssueHandler(makeOptions())({ labels: ["bug", "needs-triage"] }); + await handler(defaultMessage); + + expect(mockGithub.rest.issues.create).toHaveBeenCalledWith(expect.objectContaining({ labels: ["bug", "needs-triage"] })); + }); + }); + + describe("error handling", () => { + it("should return error result when GitHub API throws", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockRejectedValue(new Error("API rate limit exceeded")); + + const handler = await buildMissingIssueHandler(makeOptions())({}); + const result = await handler(defaultMessage); + + expect(result.success).toBe(false); + expect(result.error).toContain("API rate limit exceeded"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to create or update issue")); + }); + }); +});