diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml
index f5eb5f9582..9e4d3530d6 100644
--- a/.github/workflows/agent-performance-analyzer.lock.yml
+++ b/.github/workflows/agent-performance-analyzer.lock.yml
@@ -6925,6 +6925,15 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6936,1260 +6945,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Issue
id: create_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
@@ -8199,295 +6954,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -8497,281 +6970,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -8786,402 +6991,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml
index 1b308ee6aa..db0a34cc10 100644
--- a/.github/workflows/ai-moderator.lock.yml
+++ b/.github/workflows/ai-moderator.lock.yml
@@ -6047,6 +6047,15 @@ jobs:
outputs:
add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6058,773 +6067,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8'
- // @ts-check
- ///
-
- /**
- * Shared helper functions for safe-output scripts
- * Provides common validation and target resolution logic
- */
-
- /**
- * Parse a comma-separated list of allowed items from environment variable
- * @param {string|undefined} envValue - Environment variable value
- * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions
- */
- function parseAllowedItems(envValue) {
- const trimmed = envValue?.trim();
- if (!trimmed) {
- return undefined;
- }
- return trimmed
- .split(",")
- .map(item => item.trim())
- .filter(item => item);
- }
-
- /**
- * Parse and validate max count from environment variable
- * @param {string|undefined} envValue - Environment variable value
- * @param {number} defaultValue - Default value if not specified
- * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result
- */
- function parseMaxCount(envValue, defaultValue = 3) {
- if (!envValue) {
- return { valid: true, value: defaultValue };
- }
-
- const parsed = parseInt(envValue, 10);
- if (isNaN(parsed) || parsed < 1) {
- return {
- valid: false,
- error: `Invalid max value: ${envValue}. Must be a positive integer`,
- };
- }
-
- return { valid: true, value: parsed };
- }
-
- /**
- * Resolve the target number (issue/PR) based on configuration and context
- * @param {Object} params - Resolution parameters
- * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number)
- * @param {any} params.item - Safe output item with optional item_number or pull_request_number
- * @param {any} params.context - GitHub Actions context
- * @param {string} params.itemType - Type of item being processed (for error messages)
- * @param {boolean} params.supportsPR - Whether this safe output supports PR context
- * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result
- */
- function resolveTarget(params) {
- const { targetConfig, item, context, itemType, supportsPR = false } = params;
-
- // Check context type
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
-
- // Default target is "triggering"
- const target = targetConfig || "triggering";
-
- // Validate context for triggering mode
- if (target === "triggering") {
- if (supportsPR) {
- if (!isIssueContext && !isPRContext) {
- return {
- success: false,
- error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`,
- shouldFail: false, // Just skip, don't fail the workflow
- };
- }
- } else {
- if (!isPRContext) {
- return {
- success: false,
- error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`,
- shouldFail: false, // Just skip, don't fail the workflow
- };
- }
- }
- }
-
- // Resolve target number
- let itemNumber;
- let contextType;
-
- if (target === "*") {
- // Use item_number, issue_number, or pull_request_number from item
- const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number;
-
- if (numberField) {
- itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- return {
- success: false,
- error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`,
- shouldFail: true,
- };
- }
- contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request";
- } else {
- return {
- success: false,
- error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`,
- shouldFail: true,
- };
- }
- } else if (target !== "triggering") {
- // Explicit number
- itemNumber = parseInt(target, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- return {
- success: false,
- error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`,
- shouldFail: true,
- };
- }
- contextType = supportsPR ? "issue" : "pull request";
- } else {
- // Use triggering context
- if (isIssueContext) {
- if (context.payload.issue) {
- itemNumber = context.payload.issue.number;
- contextType = "issue";
- } else {
- return {
- success: false,
- error: "Issue context detected but no issue found in payload",
- shouldFail: true,
- };
- }
- } else if (isPRContext) {
- if (context.payload.pull_request) {
- itemNumber = context.payload.pull_request.number;
- contextType = "pull request";
- } else {
- return {
- success: false,
- error: "Pull request context detected but no pull request found in payload",
- shouldFail: true,
- };
- }
- }
- }
-
- if (!itemNumber) {
- return {
- success: false,
- error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`,
- shouldFail: true,
- };
- }
-
- return {
- success: true,
- number: itemNumber,
- contextType: contextType || (supportsPR ? "issue" : "pull request"),
- };
- }
-
- module.exports = {
- parseAllowedItems,
- parseMaxCount,
- resolveTarget,
- };
-
- EOF_80a143d8
- cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2'
- // @ts-check
- ///
-
- /**
- * Shared processor for safe-output scripts
- * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target
- */
-
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs');
- const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs');
-
- /**
- * @typedef {Object} ProcessorConfig
- * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels")
- * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels")
- * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels")
- * @property {string} itemTypeName - Name used in error messages (e.g., "label addition")
- * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false)
- * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false)
- * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false)
- * @property {Object} envVars - Environment variable names
- * @property {string} [envVars.allowed] - Env var for allowed items list
- * @property {string} [envVars.maxCount] - Env var for max count
- * @property {string} [envVars.target] - Env var for target configuration
- */
-
- /**
- * @typedef {Object} ProcessorResult
- * @property {boolean} success - Whether processing should continue
- * @property {any} [item] - The found item (when findMultiple is false)
- * @property {any[]} [items] - The found items (when findMultiple is true)
- * @property {Object} [config] - Parsed configuration
- * @property {string[]|undefined} [config.allowed] - Allowed items list
- * @property {number} [config.maxCount] - Maximum count
- * @property {string} [config.target] - Target configuration
- * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false)
- * @property {number} [targetResult.number] - Target issue/PR number
- * @property {string} [targetResult.contextType] - Type of context (issue or pull request)
- * @property {string} [reason] - Reason why processing should not continue
- */
-
- /**
- * Process the initial steps common to safe-output scripts:
- * 1. Load agent output
- * 2. Find matching item(s)
- * 3. Handle staged mode
- * 4. Parse configuration
- * 5. Resolve target (for single-item processors)
- *
- * @param {ProcessorConfig} config - Processor configuration
- * @param {Object} stagedPreviewOptions - Options for staged preview
- * @param {string} stagedPreviewOptions.title - Title for staged preview
- * @param {string} stagedPreviewOptions.description - Description for staged preview
- * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview
- * @returns {Promise} Processing result
- */
- async function processSafeOutput(config, stagedPreviewOptions) {
- const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config;
-
- // Step 1: Load agent output
- const result = loadAgentOutput();
- if (!result.success) {
- return { success: false, reason: "Agent output not available" };
- }
-
- // Step 2: Find matching item(s)
- let items;
- if (findMultiple) {
- items = result.items.filter(item => item.type === itemType);
- if (items.length === 0) {
- core.info(`No ${itemType} items found in agent output`);
- return { success: false, reason: `No ${itemType} items found` };
- }
- core.info(`Found ${items.length} ${itemType} item(s)`);
- } else {
- const item = result.items.find(item => item.type === itemType);
- if (!item) {
- core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`);
- return { success: false, reason: `No ${itemType} item found` };
- }
- items = [item];
- // Log item details based on common fields
- const itemDetails = getItemDetails(item);
- if (itemDetails) {
- core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`);
- }
- }
-
- // Step 3: Handle staged mode
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- await generateStagedPreview({
- title: stagedPreviewOptions.title,
- description: stagedPreviewOptions.description,
- items: items,
- renderItem: stagedPreviewOptions.renderItem,
- });
- return { success: false, reason: "Staged mode - preview generated" };
- }
-
- // Step 4: Parse configuration
- const safeOutputConfig = getSafeOutputConfig(configKey);
-
- // Parse allowed items (from env or config)
- const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined;
- const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed;
- if (allowed) {
- core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`);
- } else {
- core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`);
- }
-
- // Parse max count (env takes priority, then config)
- const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined;
- const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max);
- if (!maxCountResult.valid) {
- core.setFailed(maxCountResult.error);
- return { success: false, reason: "Invalid max count configuration" };
- }
- const maxCount = maxCountResult.value;
- core.info(`Max count: ${maxCount}`);
-
- // Get target configuration
- const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering";
- core.info(`${displayName} target configuration: ${target}`);
-
- // For multiple items, return early without target resolution
- if (findMultiple) {
- return {
- success: true,
- items: items,
- config: {
- allowed,
- maxCount,
- target,
- },
- };
- }
-
- // Step 5: Resolve target (for single-item processors)
- const item = items[0];
- const targetResult = resolveTarget({
- targetConfig: target,
- item: item,
- context,
- itemType: itemTypeName,
- // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only
- // If supportsIssue is true, we pass supportsPR=true to enable both contexts
- supportsPR: supportsPR || supportsIssue,
- });
-
- if (!targetResult.success) {
- if (targetResult.shouldFail) {
- core.setFailed(targetResult.error);
- } else {
- core.info(targetResult.error);
- }
- return { success: false, reason: targetResult.error };
- }
-
- return {
- success: true,
- item: item,
- config: {
- allowed,
- maxCount,
- target,
- },
- targetResult: {
- number: targetResult.number,
- contextType: targetResult.contextType,
- },
- };
- }
-
- /**
- * Get a description of item details for logging
- * @param {any} item - The safe output item
- * @returns {string|null} Description string or null
- */
- function getItemDetails(item) {
- if (item.labels && Array.isArray(item.labels)) {
- return `${item.labels.length} labels`;
- }
- if (item.reviewers && Array.isArray(item.reviewers)) {
- return `${item.reviewers.length} reviewers`;
- }
- return null;
- }
-
- /**
- * Sanitize and deduplicate an array of string items
- * @param {any[]} items - Raw items array
- * @returns {string[]} Sanitized and deduplicated array
- */
- function sanitizeItems(items) {
- return items
- .filter(item => item != null && item !== false && item !== 0)
- .map(item => String(item).trim())
- .filter(item => item)
- .filter((item, index, arr) => arr.indexOf(item) === index);
- }
-
- /**
- * Filter items by allowed list
- * @param {string[]} items - Items to filter
- * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed)
- * @returns {string[]} Filtered items
- */
- function filterByAllowed(items, allowed) {
- if (!allowed || allowed.length === 0) {
- return items;
- }
- return items.filter(item => allowed.includes(item));
- }
-
- /**
- * Limit items to max count
- * @param {string[]} items - Items to limit
- * @param {number} maxCount - Maximum number of items
- * @returns {string[]} Limited items
- */
- function limitToMaxCount(items, maxCount) {
- if (items.length > maxCount) {
- core.info(`Too many items (${items.length}), limiting to ${maxCount}`);
- return items.slice(0, maxCount);
- }
- return items;
- }
-
- /**
- * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit
- * @param {any[]} rawItems - Raw items array from agent output
- * @param {string[]|undefined} allowed - Allowed items list
- * @param {number} maxCount - Maximum number of items
- * @returns {string[]} Processed items
- */
- function processItems(rawItems, allowed, maxCount) {
- // Filter by allowed list first
- const filtered = filterByAllowed(rawItems, allowed);
-
- // Sanitize and deduplicate
- const sanitized = sanitizeItems(filtered);
-
- // Limit to max count
- return limitToMaxCount(sanitized, maxCount);
- }
-
- module.exports = {
- processSafeOutput,
- sanitizeItems,
- filterByAllowed,
- limitToMaxCount,
- processItems,
- };
-
- EOF_8f3864e2
- cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f'
- // @ts-check
- ///
-
- const fs = require("fs");
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
-
- /**
- * Load and parse the safe outputs configuration from config.json
- * @returns {object} The parsed configuration object
- */
- function loadSafeOutputsConfig() {
- const configPath = "/tmp/gh-aw/safeoutputs/config.json";
- try {
- if (!fs.existsSync(configPath)) {
- core.warning(`Config file not found at ${configPath}, using defaults`);
- return {};
- }
- const configContent = fs.readFileSync(configPath, "utf8");
- return JSON.parse(configContent);
- } catch (error) {
- core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`);
- return {};
- }
- }
-
- /**
- * Get configuration for a specific safe output type
- * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue")
- * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type
- */
- function getSafeOutputConfig(outputType) {
- const config = loadSafeOutputsConfig();
- return config[outputType] || {};
- }
-
- /**
- * Validate and sanitize a title string
- * @param {any} title - The title to validate
- * @param {string} fieldName - The name of the field for error messages (default: "title")
- * @returns {{valid: boolean, value?: string, error?: string}} Validation result
- */
- function validateTitle(title, fieldName = "title") {
- if (title === undefined || title === null) {
- return { valid: false, error: `${fieldName} is required` };
- }
-
- if (typeof title !== "string") {
- return { valid: false, error: `${fieldName} must be a string` };
- }
-
- const trimmed = title.trim();
- if (trimmed.length === 0) {
- return { valid: false, error: `${fieldName} cannot be empty` };
- }
-
- return { valid: true, value: trimmed };
- }
-
- /**
- * Validate and sanitize a body/content string
- * @param {any} body - The body to validate
- * @param {string} fieldName - The name of the field for error messages (default: "body")
- * @param {boolean} required - Whether the body is required (default: false)
- * @returns {{valid: boolean, value?: string, error?: string}} Validation result
- */
- function validateBody(body, fieldName = "body", required = false) {
- if (body === undefined || body === null) {
- if (required) {
- return { valid: false, error: `${fieldName} is required` };
- }
- return { valid: true, value: "" };
- }
-
- if (typeof body !== "string") {
- return { valid: false, error: `${fieldName} must be a string` };
- }
-
- return { valid: true, value: body };
- }
-
- /**
- * Validate and sanitize an array of labels
- * @param {any} labels - The labels to validate
- * @param {string[]|undefined} allowedLabels - Optional list of allowed labels
- * @param {number} maxCount - Maximum number of labels allowed
- * @returns {{valid: boolean, value?: string[], error?: string}} Validation result
- */
- function validateLabels(labels, allowedLabels = undefined, maxCount = 3) {
- if (!labels || !Array.isArray(labels)) {
- return { valid: false, error: "labels must be an array" };
- }
-
- // Check for removal attempts (labels starting with '-')
- for (const label of labels) {
- if (label && typeof label === "string" && label.startsWith("-")) {
- return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` };
- }
- }
-
- // Filter labels based on allowed list if provided
- let validLabels = labels;
- if (allowedLabels && allowedLabels.length > 0) {
- validLabels = labels.filter(label => allowedLabels.includes(label));
- }
-
- // Sanitize and deduplicate labels
- const uniqueLabels = validLabels
- .filter(label => label != null && label !== false && label !== 0)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
-
- // Apply max count limit
- if (uniqueLabels.length > maxCount) {
- core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`);
- return { valid: true, value: uniqueLabels.slice(0, maxCount) };
- }
-
- if (uniqueLabels.length === 0) {
- return { valid: false, error: "No valid labels found after sanitization" };
- }
-
- return { valid: true, value: uniqueLabels };
- }
-
- /**
- * Validate max count from environment variable with config fallback
- * @param {string|undefined} envValue - Environment variable value
- * @param {number|undefined} configDefault - Default from config.json
- * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1)
- * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result
- */
- function validateMaxCount(envValue, configDefault, fallbackDefault = 1) {
- // Priority: env var > config.json > fallback default
- // In production, config.json should always have the default
- // Fallback is provided for backward compatibility and testing
- const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault;
-
- if (!envValue) {
- return { valid: true, value: defaultValue };
- }
-
- const parsed = parseInt(envValue, 10);
- if (isNaN(parsed) || parsed < 1) {
- return {
- valid: false,
- error: `Invalid max value: ${envValue}. Must be a positive integer`,
- };
- }
-
- return { valid: true, value: parsed };
- }
-
- module.exports = {
- loadSafeOutputsConfig,
- getSafeOutputConfig,
- validateTitle,
- validateBody,
- validateLabels,
- validateMaxCount,
- };
-
- EOF_437e6b4f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- name: Add Labels
id: add_labels
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels'))
@@ -6836,117 +6078,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs');
- const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs');
- async function main() {
- const result = await processSafeOutput(
- {
- itemType: "add_labels",
- configKey: "add_labels",
- displayName: "Labels",
- itemTypeName: "label addition",
- supportsPR: true,
- supportsIssue: true,
- envVars: {
- allowed: "GH_AW_LABELS_ALLOWED",
- maxCount: "GH_AW_LABELS_MAX_COUNT",
- target: "GH_AW_LABELS_TARGET",
- },
- },
- {
- title: "Add Labels",
- description: "The following labels would be added if staged mode was disabled:",
- renderItem: item => {
- let content = "";
- if (item.item_number) {
- content += `**Target Issue:** #${item.item_number}\n\n`;
- } else {
- content += `**Target:** Current issue/PR\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels to add:** ${item.labels.join(", ")}\n\n`;
- }
- return content;
- },
- }
- );
- if (!result.success) {
- return;
- }
- const { item: labelsItem, config, targetResult } = result;
- if (!config || !targetResult || targetResult.number === undefined) {
- core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined");
- return;
- }
- const { allowed: allowedLabels, maxCount } = config;
- const itemNumber = targetResult.number;
- const { contextType } = targetResult;
- const requestedLabels = labelsItem.labels || [];
- core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);
- const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount);
- if (!labelsResult.valid) {
- if (labelsResult.error && labelsResult.error.includes("No valid labels")) {
- core.info("No labels to add");
- core.setOutput("labels_added", "");
- await core.summary
- .addRaw(
- `
- ## Label Addition
- No labels were added (no valid labels found in agent output).
- `
- )
- .write();
- return;
- }
- core.setFailed(labelsResult.error || "Invalid labels");
- return;
- }
- const uniqueLabels = labelsResult.value || [];
- if (uniqueLabels.length === 0) {
- core.info("No labels to add");
- core.setOutput("labels_added", "");
- await core.summary
- .addRaw(
- `
- ## Label Addition
- No labels were added (no valid labels found in agent output).
- `
- )
- .write();
- return;
- }
- core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`);
- try {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- labels: uniqueLabels,
- });
- core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`);
- core.setOutput("labels_added", uniqueLabels.join("\n"));
- const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n");
- await core.summary
- .addRaw(
- `
- ## Label Addition
- Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}:
- ${labelsListMarkdown}
- `
- )
- .write();
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to add labels: ${errorMessage}`);
- core.setFailed(`Failed to add labels: ${errorMessage}`);
- }
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_labels.cjs');
+ await main();
- name: Hide Comment
id: hide_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'hide_comment'))
@@ -6956,94 +6094,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- async function hideComment(github, nodeId, reason = "spam") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- let allowedReasons = null;
- if (process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS) {
- try {
- allowedReasons = JSON.parse(process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${allowedReasons.join(", ")}]`);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_HIDE_COMMENT_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- }
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const hideCommentItems = result.items.filter( item => item.type === "hide_comment");
- if (hideCommentItems.length === 0) {
- core.info("No hide-comment items found in agent output");
- return;
- }
- core.info(`Found ${hideCommentItems.length} hide-comment item(s)`);
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Hide Comments Preview\n\n";
- summaryContent += "The following comments would be hidden if staged mode was disabled:\n\n";
- for (let i = 0; i < hideCommentItems.length; i++) {
- const item = hideCommentItems[i];
- const reason = item.reason || "spam";
- summaryContent += `### Comment ${i + 1}\n`;
- summaryContent += `**Node ID**: ${item.comment_id}\n`;
- summaryContent += `**Action**: Would be hidden as ${reason}\n`;
- summaryContent += "\n";
- }
- core.summary.addRaw(summaryContent).write();
- return;
- }
- for (const item of hideCommentItems) {
- try {
- const commentId = item.comment_id;
- if (!commentId || typeof commentId !== "string") {
- throw new Error("comment_id is required and must be a string (GraphQL node ID)");
- }
- const reason = item.reason || "spam";
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping comment ${commentId}.`);
- continue;
- }
- }
- core.info(`Hiding comment: ${commentId} (reason: ${normalizedReason})`);
- const hideResult = await hideComment(github, commentId, normalizedReason);
- if (hideResult.isMinimized) {
- core.info(`Successfully hidden comment: ${commentId}`);
- core.setOutput("comment_id", commentId);
- core.setOutput("is_hidden", "true");
- } else {
- throw new Error(`Failed to hide comment: ${commentId}`);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to hide comment: ${errorMessage}`);
- core.setFailed(`Failed to hide comment: ${errorMessage}`);
- return;
- }
- }
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/hide_comment.cjs');
+ await main();
diff --git a/.github/workflows/archie.lock.yml b/.github/workflows/archie.lock.yml
index 0d7274f3e3..44f70c9f92 100644
--- a/.github/workflows/archie.lock.yml
+++ b/.github/workflows/archie.lock.yml
@@ -6378,6 +6378,15 @@ jobs:
add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }}
add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6389,611 +6398,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -7003,402 +6407,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml
index 3b81ec9a1a..bd62b6ff7f 100644
--- a/.github/workflows/artifacts-summary.lock.yml
+++ b/.github/workflows/artifacts-summary.lock.yml
@@ -6028,6 +6028,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6049,887 +6058,6 @@ jobs:
repositories: ${{ github.event.repository.name }}
github-api-url: ${{ github.api_url }}
permission-contents: read
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -6939,281 +6067,13 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
- name: Invalidate GitHub App token
if: always() && steps.app-token.outputs.token != ''
env:
diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml
index f43686efa8..b17122d0c6 100644
--- a/.github/workflows/audit-workflows.lock.yml
+++ b/.github/workflows/audit-workflows.lock.yml
@@ -5964,6 +5964,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -5975,887 +5984,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -6865,281 +5993,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml
index aaf2c738b7..e70d2ad9b2 100644
--- a/.github/workflows/blog-auditor.lock.yml
+++ b/.github/workflows/blog-auditor.lock.yml
@@ -5896,6 +5896,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -5907,887 +5916,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -6797,279 +5925,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml
index c0cfb16122..a7f6e6432d 100644
--- a/.github/workflows/brave.lock.yml
+++ b/.github/workflows/brave.lock.yml
@@ -6257,6 +6257,15 @@ jobs:
add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }}
add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6268,611 +6277,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -6882,402 +6286,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml
index 018038c565..b1a3aeba45 100644
--- a/.github/workflows/breaking-change-checker.lock.yml
+++ b/.github/workflows/breaking-change-checker.lock.yml
@@ -6304,6 +6304,15 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6315,644 +6324,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Issue
id: create_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
@@ -6964,293 +6335,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
diff --git a/.github/workflows/campaign-generator.lock.yml b/.github/workflows/campaign-generator.lock.yml
index 075ddadf8e..fe78a52341 100644
--- a/.github/workflows/campaign-generator.lock.yml
+++ b/.github/workflows/campaign-generator.lock.yml
@@ -6189,6 +6189,15 @@ jobs:
outputs:
assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6200,1140 +6209,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/assign_agent_helpers.cjs << 'EOF_b5665d23'
- // @ts-check
- ///
-
- /**
- * Shared helper functions for assigning coding agents (like Copilot) to issues
- * These functions use GraphQL to properly assign bot actors that cannot be assigned via gh CLI
- *
- * NOTE: All functions use the built-in `github` global object for authentication.
- * The token must be set at the step level via the `github-token` parameter in GitHub Actions.
- * This approach is required for compatibility with actions/github-script@v8.
- */
-
- /**
- * Map agent names to their GitHub bot login names
- * @type {Record}
- */
- const AGENT_LOGIN_NAMES = {
- copilot: "copilot-swe-agent",
- };
-
- /**
- * Check if an assignee is a known coding agent (bot)
- * @param {string} assignee - Assignee name (may include @ prefix)
- * @returns {string|null} Agent name if it's a known agent, null otherwise
- */
- function getAgentName(assignee) {
- // Normalize: remove @ prefix if present
- const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee;
-
- // Check if it's a known agent
- if (AGENT_LOGIN_NAMES[normalized]) {
- return normalized;
- }
-
- return null;
- }
-
- /**
- * Return list of coding agent bot login names that are currently available as assignable actors
- * (intersection of suggestedActors and known AGENT_LOGIN_NAMES values)
- * @param {string} owner
- * @param {string} repo
- * @returns {Promise}
- */
- async function getAvailableAgentLogins(owner, repo) {
- const query = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
- nodes { ... on Bot { login __typename } }
- }
- }
- }
- `;
- try {
- const response = await github.graphql(query, { owner, repo });
- const actors = response.repository?.suggestedActors?.nodes || [];
- const knownValues = Object.values(AGENT_LOGIN_NAMES);
- const available = [];
- for (const actor of actors) {
- if (actor && actor.login && knownValues.includes(actor.login)) {
- available.push(actor.login);
- }
- }
- return available.sort();
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- core.debug(`Failed to list available agent logins: ${msg}`);
- return [];
- }
- }
-
- /**
- * Find an agent in repository's suggested actors using GraphQL
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} agentName - Agent name (copilot)
- * @returns {Promise} Agent ID or null if not found
- */
- async function findAgent(owner, repo, agentName) {
- const query = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
- nodes {
- ... on Bot {
- id
- login
- __typename
- }
- }
- }
- }
- }
- `;
-
- try {
- const response = await github.graphql(query, { owner, repo });
- const actors = response.repository.suggestedActors.nodes;
-
- const loginName = AGENT_LOGIN_NAMES[agentName];
- if (!loginName) {
- core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
- return null;
- }
-
- for (const actor of actors) {
- if (actor.login === loginName) {
- return actor.id;
- }
- }
-
- const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login);
-
- core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`);
- if (available.length > 0) {
- core.info(`Available assignable coding agents: ${available.join(", ")}`);
- } else {
- core.info("No coding agents are currently assignable in this repository.");
- }
- if (agentName === "copilot") {
- core.info("Please visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot");
- }
- return null;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to find ${agentName} agent: ${errorMessage}`);
- return null;
- }
- }
-
- /**
- * Get issue details (ID and current assignees) using GraphQL
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {number} issueNumber - Issue number
- * @returns {Promise<{issueId: string, currentAssignees: string[]}|null>}
- */
- async function getIssueDetails(owner, repo, issueNumber) {
- const query = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- assignees(first: 100) {
- nodes {
- id
- }
- }
- }
- }
- }
- `;
-
- try {
- const response = await github.graphql(query, { owner, repo, issueNumber });
- const issue = response.repository.issue;
-
- if (!issue || !issue.id) {
- core.error("Could not get issue data");
- return null;
- }
-
- const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id);
-
- return {
- issueId: issue.id,
- currentAssignees: currentAssignees,
- };
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to get issue details: ${errorMessage}`);
- return null;
- }
- }
-
- /**
- * Assign agent to issue using GraphQL replaceActorsForAssignable mutation
- * @param {string} issueId - GitHub issue ID
- * @param {string} agentId - Agent ID
- * @param {string[]} currentAssignees - List of current assignee IDs
- * @param {string} agentName - Agent name for error messages
- * @returns {Promise} True if successful
- */
- async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) {
- // Build actor IDs array - include agent and preserve other assignees
- const actorIds = [agentId];
- for (const assigneeId of currentAssignees) {
- if (assigneeId !== agentId) {
- actorIds.push(assigneeId);
- }
- }
-
- const mutation = `
- mutation($assignableId: ID!, $actorIds: [ID!]!) {
- replaceActorsForAssignable(input: {
- assignableId: $assignableId,
- actorIds: $actorIds
- }) {
- __typename
- }
- }
- `;
-
- try {
- core.info("Using built-in github object for mutation");
-
- core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`);
- const response = await github.graphql(mutation, {
- assignableId: issueId,
- actorIds: actorIds,
- });
-
- if (response && response.replaceActorsForAssignable && response.replaceActorsForAssignable.__typename) {
- return true;
- } else {
- core.error("Unexpected response from GitHub API");
- return false;
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
-
- // Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues
- try {
- core.debug(`Raw GraphQL error message: ${errorMessage}`);
- if (error && typeof error === "object") {
- // Common GraphQL error shapes: error.errors (array), error.data, error.response
- const details = {};
- if (error.errors) details.errors = error.errors;
- // Some libraries wrap the payload under 'response' or 'response.data'
- if (error.response) details.response = error.response;
- if (error.data) details.data = error.data;
- // If GitHub returns an array of errors with 'type'/'message'
- if (Array.isArray(error.errors)) {
- details.compactMessages = error.errors.map(e => e.message).filter(Boolean);
- }
- const serialized = JSON.stringify(details, (_k, v) => v, 2);
- if (serialized && serialized !== "{}") {
- core.debug(`Raw GraphQL error details: ${serialized}`);
- // Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it
- core.error("Raw GraphQL error details (for troubleshooting):");
- // Split large JSON for readability
- for (const line of serialized.split(/\n/)) {
- if (line.trim()) core.error(line);
- }
- }
- }
- } catch (loggingErr) {
- // Never fail assignment because of debug logging
- core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`);
- }
-
- // Check for permission-related errors
- if (errorMessage.includes("Resource not accessible by personal access token") || errorMessage.includes("Resource not accessible by integration") || errorMessage.includes("Insufficient permissions to assign")) {
- // Attempt fallback mutation addAssigneesToAssignable when replaceActorsForAssignable is forbidden
- core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
- try {
- const fallbackMutation = `
- mutation($assignableId: ID!, $assigneeIds: [ID!]!) {
- addAssigneesToAssignable(input: {
- assignableId: $assignableId,
- assigneeIds: $assigneeIds
- }) {
- clientMutationId
- }
- }
- `;
- core.info("Using built-in github object for fallback mutation");
- core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`);
- const fallbackResp = await github.graphql(fallbackMutation, {
- assignableId: issueId,
- assigneeIds: [agentId],
- });
- if (fallbackResp && fallbackResp.addAssigneesToAssignable) {
- core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
- return true;
- } else {
- core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance.");
- }
- } catch (fallbackError) {
- const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
- core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
- }
- logPermissionError(agentName);
- } else {
- core.error(`Failed to assign ${agentName}: ${errorMessage}`);
- }
- return false;
- }
- }
-
- /**
- * Log detailed permission error guidance
- * @param {string} agentName - Agent name for error messages
- */
- function logPermissionError(agentName) {
- core.error(`Failed to assign ${agentName}: Insufficient permissions`);
- core.error("");
- core.error("Assigning Copilot agents requires:");
- core.error(" 1. All four workflow permissions:");
- core.error(" - actions: write");
- core.error(" - contents: write");
- core.error(" - issues: write");
- core.error(" - pull-requests: write");
- core.error("");
- core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
- core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
- core.error("");
- core.error(" 3. Repository settings:");
- core.error(" - Actions must have write permissions");
- core.error(" - Go to: Settings > Actions > General > Workflow permissions");
- core.error(" - Select: 'Read and write permissions'");
- core.error("");
- core.error(" 4. Organization/Enterprise settings:");
- core.error(" - Check if your org restricts bot assignments");
- core.error(" - Verify Copilot is enabled for your repository");
- core.error("");
- core.info("For more information, see: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
- }
-
- /**
- * Generate permission error summary content for step summary
- * @returns {string} Markdown content for permission error guidance
- */
- function generatePermissionErrorSummary() {
- let content = "\n### β οΈ Permission Requirements\n\n";
- content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
- content += "```yaml\n";
- content += "permissions:\n";
- content += " actions: write\n";
- content += " contents: write\n";
- content += " issues: write\n";
- content += " pull-requests: write\n";
- content += "```\n\n";
- content += "**Token capability note:**\n";
- content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
- content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
- content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
- content += "**Recommended remediation paths:**\n";
- content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) β use installation token in job.\n";
- content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
- content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
- content += "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
- content += "π Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
- return content;
- }
-
- /**
- * Assign an agent to an issue using GraphQL
- * This is the main entry point for assigning agents from other scripts
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {number} issueNumber - Issue number
- * @param {string} agentName - Agent name (e.g., "copilot")
- * @returns {Promise<{success: boolean, error?: string}>}
- */
- async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) {
- // Check if agent is supported
- if (!AGENT_LOGIN_NAMES[agentName]) {
- const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`;
- core.warning(error);
- return { success: false, error };
- }
-
- try {
- // Find agent using the github object authenticated via step-level github-token
- core.info(`Looking for ${agentName} coding agent...`);
- const agentId = await findAgent(owner, repo, agentName);
- if (!agentId) {
- const error = `${agentName} coding agent is not available for this repository`;
- // Enrich with available agent logins
- const available = await getAvailableAgentLogins(owner, repo);
- const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error;
- return { success: false, error: enrichedError };
- }
- core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
-
- // Get issue details (ID and current assignees) via GraphQL
- core.info("Getting issue details...");
- const issueDetails = await getIssueDetails(owner, repo, issueNumber);
- if (!issueDetails) {
- return { success: false, error: "Failed to get issue details" };
- }
-
- core.info(`Issue ID: ${issueDetails.issueId}`);
-
- // Check if agent is already assigned
- if (issueDetails.currentAssignees.includes(agentId)) {
- core.info(`${agentName} is already assigned to issue #${issueNumber}`);
- return { success: true };
- }
-
- // Assign agent using GraphQL mutation
- core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
- const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
-
- if (!success) {
- return { success: false, error: `Failed to assign ${agentName} via GraphQL` };
- }
-
- core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
- return { success: true };
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- return { success: false, error: errorMessage };
- }
- }
-
- module.exports = {
- AGENT_LOGIN_NAMES,
- getAgentName,
- getAvailableAgentLogins,
- findAgent,
- getIssueDetails,
- assignAgentToIssue,
- logPermissionError,
- generatePermissionErrorSummary,
- assignAgentToIssueByName,
- };
-
- EOF_b5665d23
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_4d21ccbd'
- // @ts-check
- ///
-
- /**
- * Shared context helper functions for update workflows (issues, pull requests, etc.)
- *
- * This module provides reusable functions for determining if we're in a valid
- * context for updating a specific entity type and extracting entity numbers
- * from GitHub event payloads.
- *
- * @module update_context_helpers
- */
-
- /**
- * Check if the current context is a valid issue context
- * @param {string} eventName - GitHub event name
- * @param {any} _payload - GitHub event payload (unused but kept for interface consistency)
- * @returns {boolean} Whether context is valid for issue updates
- */
- function isIssueContext(eventName, _payload) {
- return eventName === "issues" || eventName === "issue_comment";
- }
-
- /**
- * Get issue number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} Issue number or undefined
- */
- function getIssueNumber(payload) {
- return payload?.issue?.number;
- }
-
- /**
- * Check if the current context is a valid pull request context
- * @param {string} eventName - GitHub event name
- * @param {any} payload - GitHub event payload
- * @returns {boolean} Whether context is valid for PR updates
- */
- function isPRContext(eventName, payload) {
- const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target";
-
- // Also check for issue_comment on a PR
- const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request;
-
- return isPR || !!isIssueCommentOnPR;
- }
-
- /**
- * Get pull request number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} PR number or undefined
- */
- function getPRNumber(payload) {
- if (payload?.pull_request) {
- return payload.pull_request.number;
- }
- // For issue_comment events on PRs, the PR number is in issue.number
- if (payload?.issue && payload?.issue?.pull_request) {
- return payload.issue.number;
- }
- return undefined;
- }
-
- /**
- * Check if the current context is a valid discussion context
- * @param {string} eventName - GitHub event name
- * @param {any} _payload - GitHub event payload (unused but kept for interface consistency)
- * @returns {boolean} Whether context is valid for discussion updates
- */
- function isDiscussionContext(eventName, _payload) {
- return eventName === "discussion" || eventName === "discussion_comment";
- }
-
- /**
- * Get discussion number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} Discussion number or undefined
- */
- function getDiscussionNumber(payload) {
- return payload?.discussion?.number;
- }
-
- module.exports = {
- isIssueContext,
- getIssueNumber,
- isPRContext,
- getPRNumber,
- isDiscussionContext,
- getDiscussionNumber,
- };
-
- EOF_4d21ccbd
- cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_5e2e1ea7'
- // @ts-check
- ///
-
- /**
- * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.)
- *
- * This module depends on GitHub Actions environment globals provided by actions/github-script:
- * - core: @actions/core module for logging and outputs
- * - github: @octokit/rest instance for GitHub API calls
- * - context: GitHub Actions context with event payload and repository info
- *
- * @module update_runner
- */
-
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
-
- /**
- * @typedef {Object} UpdateRunnerConfig
- * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request")
- * @property {string} displayName - Human-readable name (e.g., "issue", "pull request")
- * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests")
- * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number")
- * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number")
- * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url")
- * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid
- * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload
- * @property {boolean} supportsStatus - Whether this type supports status updates
- * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace)
- * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview
- * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call
- * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item
- */
-
- /**
- * Resolve the target number for an update operation
- * @param {Object} params - Resolution parameters
- * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number)
- * @param {any} params.item - Update item with optional explicit number field
- * @param {string} params.numberField - Field name for explicit number
- * @param {boolean} params.isValidContext - Whether current context is valid
- * @param {number|undefined} params.contextNumber - Number from triggering context
- * @param {string} params.displayName - Display name for error messages
- * @returns {{success: true, number: number} | {success: false, error: string}}
- */
- function resolveTargetNumber(params) {
- const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params;
-
- if (updateTarget === "*") {
- // For target "*", we need an explicit number from the update item
- const explicitNumber = item[numberField];
- if (explicitNumber) {
- const parsed = parseInt(explicitNumber, 10);
- if (isNaN(parsed) || parsed <= 0) {
- return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` };
- }
- return { success: true, number: parsed };
- } else {
- return { success: false, error: `Target is "*" but no ${numberField} specified in update item` };
- }
- } else if (updateTarget && updateTarget !== "triggering") {
- // Explicit number specified in target
- const parsed = parseInt(updateTarget, 10);
- if (isNaN(parsed) || parsed <= 0) {
- return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` };
- }
- return { success: true, number: parsed };
- } else {
- // Default behavior: use triggering context
- if (isValidContext && contextNumber) {
- return { success: true, number: contextNumber };
- }
- return { success: false, error: `Could not determine ${displayName} number` };
- }
- }
-
- /**
- * Build update data based on allowed fields and provided values
- * @param {Object} params - Build parameters
- * @param {any} params.item - Update item with field values
- * @param {boolean} params.canUpdateStatus - Whether status updates are allowed
- * @param {boolean} params.canUpdateTitle - Whether title updates are allowed
- * @param {boolean} params.canUpdateBody - Whether body updates are allowed
- * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed
- * @param {boolean} params.supportsStatus - Whether this type supports status
- * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}}
- */
- function buildUpdateData(params) {
- const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params;
-
- /** @type {any} */
- const updateData = {};
- let hasUpdates = false;
- const logMessages = [];
-
- // Handle status update (only for types that support it, like issues)
- if (supportsStatus && canUpdateStatus && item.status !== undefined) {
- if (item.status === "open" || item.status === "closed") {
- updateData.state = item.status;
- hasUpdates = true;
- logMessages.push(`Will update status to: ${item.status}`);
- } else {
- logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`);
- }
- }
-
- // Handle title update
- let titleForDedup = null;
- if (canUpdateTitle && item.title !== undefined) {
- const trimmedTitle = typeof item.title === "string" ? item.title.trim() : "";
- if (trimmedTitle.length > 0) {
- updateData.title = trimmedTitle;
- titleForDedup = trimmedTitle;
- hasUpdates = true;
- logMessages.push(`Will update title to: ${trimmedTitle}`);
- } else {
- logMessages.push("Invalid title value: must be a non-empty string");
- }
- }
-
- // Handle body update (with title deduplication)
- if (canUpdateBody && item.body !== undefined) {
- if (typeof item.body === "string") {
- let processedBody = item.body;
-
- // If we're updating the title at the same time, remove duplicate title from body
- if (titleForDedup) {
- processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody);
- }
-
- updateData.body = processedBody;
- hasUpdates = true;
- logMessages.push(`Will update body (length: ${processedBody.length})`);
- } else {
- logMessages.push("Invalid body value: must be a string");
- }
- }
-
- // Handle labels update
- if (canUpdateLabels && item.labels !== undefined) {
- if (Array.isArray(item.labels)) {
- updateData.labels = item.labels;
- hasUpdates = true;
- logMessages.push(`Will update labels to: ${item.labels.join(", ")}`);
- } else {
- logMessages.push("Invalid labels value: must be an array");
- }
- }
-
- return { hasUpdates, updateData, logMessages };
- }
-
- /**
- * Run the update workflow with the provided configuration
- * @param {UpdateRunnerConfig} config - Configuration for the update runner
- * @returns {Promise} Array of updated items or undefined
- */
- async function runUpdateWorkflow(config) {
- const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config;
-
- // Check if we're in staged mode
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
-
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
-
- // Find all update items
- const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType);
- if (updateItems.length === 0) {
- core.info(`No ${itemType} items found in agent output`);
- return;
- }
-
- core.info(`Found ${updateItems.length} ${itemType} item(s)`);
-
- // If in staged mode, emit step summary instead of updating
- if (isStaged) {
- await generateStagedPreview({
- title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`,
- description: `The following ${displayName} updates would be applied if staged mode was disabled:`,
- items: updateItems,
- renderItem: renderStagedItem,
- });
- return;
- }
-
- // Get the configuration from environment variables
- const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering";
- const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true";
- const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true";
- const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true";
- const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true";
-
- core.info(`Update target configuration: ${updateTarget}`);
- if (supportsStatus) {
- core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`);
- } else {
- core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`);
- }
-
- // Check context validity
- const contextIsValid = isValidContext(context.eventName, context.payload);
- const contextNumber = getContextNumber(context.payload);
-
- // Validate context based on target configuration
- if (updateTarget === "triggering" && !contextIsValid) {
- core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`);
- return;
- }
-
- const updatedItems = [];
-
- // Process each update item
- for (let i = 0; i < updateItems.length; i++) {
- const updateItem = updateItems[i];
- core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`);
-
- // Resolve target number
- const targetResult = resolveTargetNumber({
- updateTarget,
- item: updateItem,
- numberField,
- isValidContext: contextIsValid,
- contextNumber,
- displayName,
- });
-
- if (!targetResult.success) {
- core.info(targetResult.error);
- continue;
- }
-
- const targetNumber = targetResult.number;
- core.info(`Updating ${displayName} #${targetNumber}`);
-
- // Build update data
- const { hasUpdates, updateData, logMessages } = buildUpdateData({
- item: updateItem,
- canUpdateStatus,
- canUpdateTitle,
- canUpdateBody,
- canUpdateLabels,
- supportsStatus,
- });
-
- // Log all messages
- for (const msg of logMessages) {
- core.info(msg);
- }
-
- // Handle body operation for types that support it (like PRs with append/prepend)
- if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") {
- // The body was already added by buildUpdateData, but we need to handle operations
- // This will be handled by the executeUpdate function for PR-specific logic
- updateData._operation = updateItem.operation || "append";
- updateData._rawBody = updateItem.body;
- }
-
- if (!hasUpdates) {
- core.info("No valid updates to apply for this item");
- continue;
- }
-
- try {
- // Execute the update using the provided function
- const updatedItem = await executeUpdate(github, context, targetNumber, updateData);
- core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`);
- updatedItems.push(updatedItem);
-
- // Set output for the last updated item (for backward compatibility)
- if (i === updateItems.length - 1) {
- core.setOutput(outputNumberKey, updatedItem.number);
- core.setOutput(outputUrlKey, updatedItem.html_url);
- }
- } catch (error) {
- core.error(`β Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
-
- // Write summary for all updated items
- if (updatedItems.length > 0) {
- let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`;
- for (const item of updatedItems) {
- summaryContent += getSummaryLine(item);
- }
- await core.summary.addRaw(summaryContent).write();
- }
-
- core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`);
- return updatedItems;
- }
-
- /**
- * @typedef {Object} RenderStagedItemConfig
- * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request")
- * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number")
- * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:")
- * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request")
- * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates
- */
-
- /**
- * Create a render function for staged preview items
- * @param {RenderStagedItemConfig} config - Configuration for the renderer
- * @returns {(item: any, index: number) => string} Render function
- */
- function createRenderStagedItem(config) {
- const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config;
-
- return function renderStagedItem(item, index) {
- let content = `#### ${entityName} Update ${index + 1}\n`;
- if (item[numberField]) {
- content += `**${targetLabel}** #${item[numberField]}\n\n`;
- } else {
- content += `**Target:** ${currentTargetText}\n\n`;
- }
-
- if (item.title !== undefined) {
- content += `**New Title:** ${item.title}\n\n`;
- }
- if (item.body !== undefined) {
- if (includeOperation) {
- const operation = item.operation || "append";
- content += `**Operation:** ${operation}\n`;
- content += `**Body Content:**\n${item.body}\n\n`;
- } else {
- content += `**New Body:**\n${item.body}\n\n`;
- }
- }
- if (item.status !== undefined) {
- content += `**New Status:** ${item.status}\n\n`;
- }
- return content;
- };
- }
-
- /**
- * @typedef {Object} SummaryLineConfig
- * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR")
- */
-
- /**
- * Create a summary line generator function
- * @param {SummaryLineConfig} config - Configuration for the summary generator
- * @returns {(item: any) => string} Summary line generator function
- */
- function createGetSummaryLine(config) {
- const { entityPrefix } = config;
-
- return function getSummaryLine(item) {
- return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`;
- };
- }
-
- /**
- * @typedef {Object} UpdateHandlerConfig
- * @property {string} itemType - Type of item in agent output (e.g., "update_issue")
- * @property {string} displayName - Human-readable name (e.g., "issue")
- * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues")
- * @property {string} numberField - Field name for explicit number (e.g., "issue_number")
- * @property {string} outputNumberKey - Output key for number (e.g., "issue_number")
- * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url")
- * @property {string} entityName - Display name for entity (e.g., "Issue", "Pull Request")
- * @property {string} entityPrefix - Prefix for summary lines (e.g., "Issue", "PR")
- * @property {string} targetLabel - Label for target in staged preview (e.g., "Target Issue:")
- * @property {string} currentTargetText - Text for current target (e.g., "Current issue")
- * @property {boolean} supportsStatus - Whether this type supports status updates
- * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace)
- * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid
- * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload
- * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call
- */
-
- /**
- * Create an update handler from configuration
- * This factory function eliminates boilerplate by generating all the
- * render functions, summary line generators, and the main handler
- * @param {UpdateHandlerConfig} config - Handler configuration
- * @returns {() => Promise} Main handler function
- */
- function createUpdateHandler(config) {
- // Create render function for staged preview
- const renderStagedItem = createRenderStagedItem({
- entityName: config.entityName,
- numberField: config.numberField,
- targetLabel: config.targetLabel,
- currentTargetText: config.currentTargetText,
- includeOperation: config.supportsOperation,
- });
-
- // Create summary line generator
- const getSummaryLine = createGetSummaryLine({
- entityPrefix: config.entityPrefix,
- });
-
- // Return the main handler function
- return async function main() {
- return await runUpdateWorkflow({
- itemType: config.itemType,
- displayName: config.displayName,
- displayNamePlural: config.displayNamePlural,
- numberField: config.numberField,
- outputNumberKey: config.outputNumberKey,
- outputUrlKey: config.outputUrlKey,
- isValidContext: config.isValidContext,
- getContextNumber: config.getContextNumber,
- supportsStatus: config.supportsStatus,
- supportsOperation: config.supportsOperation,
- renderStagedItem,
- executeUpdate: config.executeUpdate,
- getSummaryLine,
- });
- };
- }
-
- module.exports = {
- runUpdateWorkflow,
- resolveTargetNumber,
- buildUpdateData,
- createRenderStagedItem,
- createGetSummaryLine,
- createUpdateHandler,
- };
-
- EOF_5e2e1ea7
- name: Assign To Agent
id: assign_to_agent
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent'))
@@ -7343,175 +6218,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_AGENT_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, assignAgentToIssue, generatePermissionErrorSummary } = require('/tmp/gh-aw/scripts/assign_agent_helpers.cjs');
- async function main() {
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const assignItems = result.items.filter(item => item.type === "assign_to_agent");
- if (assignItems.length === 0) {
- core.info("No assign_to_agent items found in agent output");
- return;
- }
- core.info(`Found ${assignItems.length} assign_to_agent item(s)`);
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- await generateStagedPreview({
- title: "Assign to Agent",
- description: "The following agent assignments would be made if staged mode was disabled:",
- items: assignItems,
- renderItem: item => {
- let content = `**Issue:** #${item.issue_number}\n`;
- content += `**Agent:** ${item.agent || "copilot"}\n`;
- content += "\n";
- return content;
- },
- });
- return;
- }
- const defaultAgent = process.env.GH_AW_AGENT_DEFAULT?.trim() || "copilot";
- core.info(`Default agent: ${defaultAgent}`);
- const maxCountEnv = process.env.GH_AW_AGENT_MAX_COUNT;
- const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 1;
- if (isNaN(maxCount) || maxCount < 1) {
- core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`);
- return;
- }
- core.info(`Max count: ${maxCount}`);
- const itemsToProcess = assignItems.slice(0, maxCount);
- if (assignItems.length > maxCount) {
- core.warning(`Found ${assignItems.length} agent assignments, but max is ${maxCount}. Processing first ${maxCount}.`);
- }
- const targetRepoEnv = process.env.GH_AW_TARGET_REPO?.trim();
- let targetOwner = context.repo.owner;
- let targetRepo = context.repo.repo;
- if (targetRepoEnv) {
- const parts = targetRepoEnv.split("/");
- if (parts.length === 2) {
- targetOwner = parts[0];
- targetRepo = parts[1];
- core.info(`Using target repository: ${targetOwner}/${targetRepo}`);
- } else {
- core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`);
- }
- }
- const agentCache = {};
- const results = [];
- for (const item of itemsToProcess) {
- const issueNumber = typeof item.issue_number === "number" ? item.issue_number : parseInt(String(item.issue_number), 10);
- const agentName = item.agent || defaultAgent;
- if (isNaN(issueNumber) || issueNumber <= 0) {
- core.error(`Invalid issue_number: ${item.issue_number}`);
- continue;
- }
- if (!AGENT_LOGIN_NAMES[agentName]) {
- core.warning(`Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
- results.push({
- issue_number: issueNumber,
- agent: agentName,
- success: false,
- error: `Unsupported agent: ${agentName}`,
- });
- continue;
- }
- try {
- let agentId = agentCache[agentName];
- if (!agentId) {
- core.info(`Looking for ${agentName} coding agent...`);
- agentId = await findAgent(targetOwner, targetRepo, agentName);
- if (!agentId) {
- throw new Error(`${agentName} coding agent is not available for this repository`);
- }
- agentCache[agentName] = agentId;
- core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
- }
- core.info("Getting issue details...");
- const issueDetails = await getIssueDetails(targetOwner, targetRepo, issueNumber);
- if (!issueDetails) {
- throw new Error("Failed to get issue details");
- }
- core.info(`Issue ID: ${issueDetails.issueId}`);
- if (issueDetails.currentAssignees.includes(agentId)) {
- core.info(`${agentName} is already assigned to issue #${issueNumber}`);
- results.push({
- issue_number: issueNumber,
- agent: agentName,
- success: true,
- });
- continue;
- }
- core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
- const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
- if (!success) {
- throw new Error(`Failed to assign ${agentName} via GraphQL`);
- }
- core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
- results.push({
- issue_number: issueNumber,
- agent: agentName,
- success: true,
- });
- } catch (error) {
- let errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("coding agent is not available for this repository")) {
- try {
- const available = await getAvailableAgentLogins(targetOwner, targetRepo);
- if (available.length > 0) {
- errorMessage += ` (available agents: ${available.join(", ")})`;
- }
- } catch (e) {
- core.debug("Failed to enrich unavailable agent message with available list");
- }
- }
- core.error(`Failed to assign agent "${agentName}" to issue #${issueNumber}: ${errorMessage}`);
- results.push({
- issue_number: issueNumber,
- agent: agentName,
- success: false,
- error: errorMessage,
- });
- }
- }
- const successCount = results.filter(r => r.success).length;
- const failureCount = results.filter(r => !r.success).length;
- let summaryContent = "## Agent Assignment\n\n";
- if (successCount > 0) {
- summaryContent += `β
Successfully assigned ${successCount} agent(s):\n\n`;
- for (const result of results.filter(r => r.success)) {
- summaryContent += `- Issue #${result.issue_number} β Agent: ${result.agent}\n`;
- }
- summaryContent += "\n";
- }
- if (failureCount > 0) {
- summaryContent += `β Failed to assign ${failureCount} agent(s):\n\n`;
- for (const result of results.filter(r => !r.success)) {
- summaryContent += `- Issue #${result.issue_number} β Agent: ${result.agent}: ${result.error}\n`;
- }
- const hasPermissionError = results.some(r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions")));
- if (hasPermissionError) {
- summaryContent += generatePermissionErrorSummary();
- }
- }
- await core.summary.addRaw(summaryContent).write();
- const assignedAgents = results
- .filter(r => r.success)
- .map(r => `${r.issue_number}:${r.agent}`)
- .join("\n");
- core.setOutput("assigned_agents", assignedAgents);
- if (failureCount > 0) {
- core.setFailed(`Failed to assign ${failureCount} agent(s)`);
- }
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/assign_to_agent.cjs');
+ await main();
- name: Update Issue
id: update_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_issue'))
@@ -7521,39 +6234,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { createUpdateHandler } = require('/tmp/gh-aw/scripts/update_runner.cjs');
- const { isIssueContext, getIssueNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs');
- async function executeIssueUpdate(github, context, issueNumber, updateData) {
- const { _operation, _rawBody, ...apiData } = updateData;
- const { data: issue } = await github.rest.issues.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issueNumber,
- ...apiData,
- });
- return issue;
- }
- const main = createUpdateHandler({
- itemType: "update_issue",
- displayName: "issue",
- displayNamePlural: "issues",
- numberField: "issue_number",
- outputNumberKey: "issue_number",
- outputUrlKey: "issue_url",
- entityName: "Issue",
- entityPrefix: "Issue",
- targetLabel: "Target Issue:",
- currentTargetText: "Current issue",
- supportsStatus: true,
- supportsOperation: false,
- isValidContext: isIssueContext,
- getContextNumber: getIssueNumber,
- executeUpdate: executeIssueUpdate,
- });
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/update_issue.cjs');
+ await main();
diff --git a/.github/workflows/campaign-manager.lock.yml b/.github/workflows/campaign-manager.lock.yml
index dab14c70a4..adb5a313ac 100644
--- a/.github/workflows/campaign-manager.lock.yml
+++ b/.github/workflows/campaign-manager.lock.yml
@@ -6793,1569 +6793,42 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
- - name: Download agent output artifact
- continue-on-error: true
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
- name: agent_output.json
- path: /tmp/gh-aw/safeoutputs/
- - name: Setup agent output environment variable
- run: |
- mkdir -p /tmp/gh-aw/safeoutputs/
- find "/tmp/gh-aw/safeoutputs/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- - name: Create Issue
- id: create_issue
- if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ destination: /tmp/gh-aw/actions
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: agent_output.json
+ path: /tmp/gh-aw/safeoutputs/
+ - name: Setup agent output environment variable
+ run: |
+ mkdir -p /tmp/gh-aw/safeoutputs/
+ find "/tmp/gh-aw/safeoutputs/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Create Issue
+ id: create_issue
+ if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -8365,281 +6838,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -8654,404 +6859,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
- name: Update Project
id: update_project
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project'))
@@ -9061,424 +6875,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- function logGraphQLError(error, operation) {
- (core.info(`GraphQL Error during: ${operation}`), core.info(`Message: ${error.message}`));
- const errorList = Array.isArray(error.errors) ? error.errors : [],
- hasInsufficientScopes = errorList.some(e => e && "INSUFFICIENT_SCOPES" === e.type),
- hasNotFound = errorList.some(e => e && "NOT_FOUND" === e.type);
- (hasInsufficientScopes
- ? core.info(
- "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project."
- )
- : hasNotFound &&
- /projectV2\b/.test(error.message) &&
- core.info(
- "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project."
- ),
- error.errors &&
- (core.info(`Errors array (${error.errors.length} error(s)):`),
- error.errors.forEach((err, idx) => {
- (core.info(` [${idx + 1}] ${err.message}`),
- err.type && core.info(` Type: ${err.type}`),
- err.path && core.info(` Path: ${JSON.stringify(err.path)}`),
- err.locations && core.info(` Locations: ${JSON.stringify(err.locations)}`));
- })),
- error.request && core.info(`Request: ${JSON.stringify(error.request, null, 2)}`),
- error.data && core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`));
- }
- function parseProjectInput(projectUrl) {
- if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
- const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/);
- if (!urlMatch) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
- return urlMatch[1];
- }
- function parseProjectUrl(projectUrl) {
- if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
- const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/);
- if (!match) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
- return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] };
- }
- async function listAccessibleProjectsV2(projectInfo) {
- const baseQuery =
- "projectsV2(first: 100) {\n totalCount\n nodes {\n id\n number\n title\n closed\n url\n }\n edges {\n node {\n id\n number\n title\n closed\n url\n }\n }\n }";
- if ("orgs" === projectInfo.scope) {
- const result = await github.graphql(`query($login: String!) {\n organization(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }),
- conn = result && result.organization && result.organization.projectsV2,
- rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [],
- rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [],
- nodeNodes = rawNodes.filter(Boolean),
- edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean),
- unique = new Map();
- for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n);
- return {
- nodes: Array.from(unique.values()),
- totalCount: conn && conn.totalCount,
- diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length },
- };
- }
- const result = await github.graphql(`query($login: String!) {\n user(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }),
- conn = result && result.user && result.user.projectsV2,
- rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [],
- rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [],
- nodeNodes = rawNodes.filter(Boolean),
- edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean),
- unique = new Map();
- for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n);
- return {
- nodes: Array.from(unique.values()),
- totalCount: conn && conn.totalCount,
- diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length },
- };
- }
- function summarizeProjectsV2(projects, limit = 20) {
- if (!Array.isArray(projects) || 0 === projects.length) return "(none)";
- const normalized = projects
- .filter(p => p && "number" == typeof p.number && "string" == typeof p.title)
- .slice(0, limit)
- .map(p => `#${p.number} ${p.closed ? "(closed) " : ""}${p.title}`);
- return normalized.length > 0 ? normalized.join("; ") : "(none)";
- }
- function summarizeEmptyProjectsV2List(list) {
- const total = "number" == typeof list.totalCount ? list.totalCount : void 0,
- d = list && list.diagnostics,
- diag = d ? ` nodes=${d.rawNodesCount} (null=${d.nullNodesCount}), edges=${d.rawEdgesCount} (nullNode=${d.nullEdgeNodesCount})` : "";
- return "number" == typeof total && total > 0
- ? `(none; totalCount=${total} but returned 0 readable project nodes${diag}. This often indicates the token can see the org/user but lacks Projects v2 access, or the org enforces SSO and the token is not authorized.)`
- : `(none${diag})`;
- }
- async function resolveProjectV2(projectInfo, projectNumberInt) {
- try {
- if ("orgs" === projectInfo.scope) {
- const direct = await github.graphql(
- "query($login: String!, $number: Int!) {\n organization(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }",
- { login: projectInfo.ownerLogin, number: projectNumberInt }
- ),
- project = direct && direct.organization && direct.organization.projectV2;
- if (project) return project;
- } else {
- const direct = await github.graphql(
- "query($login: String!, $number: Int!) {\n user(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }",
- { login: projectInfo.ownerLogin, number: projectNumberInt }
- ),
- project = direct && direct.user && direct.user.projectV2;
- if (project) return project;
- }
- } catch (error) {
- core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${error.message}`);
- }
- const list = await listAccessibleProjectsV2(projectInfo),
- nodes = Array.isArray(list.nodes) ? list.nodes : [],
- found = nodes.find(p => p && "number" == typeof p.number && p.number === projectNumberInt);
- if (found) return found;
- const summary = nodes.length > 0 ? summarizeProjectsV2(nodes) : summarizeEmptyProjectsV2List(list),
- total = "number" == typeof list.totalCount ? ` (totalCount=${list.totalCount})` : "",
- who = "orgs" === projectInfo.scope ? `org ${projectInfo.ownerLogin}` : `user ${projectInfo.ownerLogin}`;
- throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`);
- }
- function generateCampaignId(projectUrl, projectNumber) {
- const urlMatch = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects/);
- return `${`${urlMatch ? urlMatch[2] : "project"}-project-${projectNumber}`
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "")
- .substring(0, 30)}-${Date.now().toString(36).substring(0, 8)}`;
- }
- async function updateProject(output) {
- const { owner, repo } = context.repo,
- projectInfo = parseProjectUrl(output.project),
- projectNumberFromUrl = projectInfo.projectNumber,
- campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl);
- try {
- let repoResult;
- (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information..."));
- try {
- repoResult = await github.graphql(
- "query($owner: String!, $repo: String!) {\n repository(owner: $owner, name: $repo) {\n id\n owner {\n id\n __typename\n }\n }\n }",
- { owner, repo }
- );
- } catch (error) {
- throw (logGraphQLError(error, "Fetching repository information"), error);
- }
- const repositoryId = repoResult.repository.id,
- ownerType = repoResult.repository.owner.__typename;
- core.info(`β Repository: ${owner}/${repo} (${ownerType})`);
- try {
- const viewerResult = await github.graphql("query {\n viewer {\n login\n }\n }");
- viewerResult && viewerResult.viewer && viewerResult.viewer.login && core.info(`β Authenticated as: ${viewerResult.viewer.login}`);
- } catch (viewerError) {
- core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`);
- }
- let projectId;
- core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`);
- let resolvedProjectNumber = projectNumberFromUrl;
- try {
- const projectNumberInt = parseInt(projectNumberFromUrl, 10);
- if (!Number.isFinite(projectNumberInt)) throw new Error(`Invalid project number parsed from URL: ${projectNumberFromUrl}`);
- const project = await resolveProjectV2(projectInfo, projectNumberInt);
- ((projectId = project.id), (resolvedProjectNumber = String(project.number)), core.info(`β Resolved project #${resolvedProjectNumber} (${projectInfo.ownerLogin}) (ID: ${projectId})`));
- } catch (error) {
- throw (logGraphQLError(error, "Resolving project from URL"), error);
- }
- core.info("[3/5] Linking project to repository...");
- try {
- await github.graphql(
- "mutation($projectId: ID!, $repositoryId: ID!) {\n linkProjectV2ToRepository(input: {\n projectId: $projectId,\n repositoryId: $repositoryId\n }) {\n repository {\n id\n }\n }\n }",
- { projectId, repositoryId }
- );
- } catch (linkError) {
- (linkError.message && linkError.message.includes("already linked")) || (logGraphQLError(linkError, "Linking project to repository"), core.warning(`Could not link project: ${linkError.message}`));
- }
- (core.info("β Project linked to repository"), core.info("[4/5] Processing content (issue/PR/draft) if specified..."));
- const hasContentNumber = void 0 !== output.content_number && null !== output.content_number,
- hasIssue = void 0 !== output.issue && null !== output.issue,
- hasPullRequest = void 0 !== output.pull_request && null !== output.pull_request,
- values = [];
- if (
- (hasContentNumber && values.push({ key: "content_number", value: output.content_number }),
- hasIssue && values.push({ key: "issue", value: output.issue }),
- hasPullRequest && values.push({ key: "pull_request", value: output.pull_request }),
- values.length > 1)
- ) {
- const uniqueValues = [...new Set(values.map(v => String(v.value)))],
- list = values.map(v => `${v.key}=${v.value}`).join(", "),
- descriptor = uniqueValues.length > 1 ? "different values" : `same value "${uniqueValues[0]}"`;
- core.warning(`Multiple content number fields (${descriptor}): ${list}. Using priority content_number > issue > pull_request.`);
- }
- (hasIssue && core.warning('Field "issue" deprecated; use "content_number" instead.'), hasPullRequest && core.warning('Field "pull_request" deprecated; use "content_number" instead.'));
- if ("draft_issue" === output.content_type) {
- values.length > 0 && core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".');
- const draftTitle = "string" == typeof output.draft_title ? output.draft_title.trim() : "";
- if (!draftTitle) throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.');
- const draftBody = "string" == typeof output.draft_body ? output.draft_body : void 0;
- const itemId = (
- await github.graphql(
- "mutation($projectId: ID!, $title: String!, $body: String) {\n addProjectV2DraftIssue(input: {\n projectId: $projectId,\n title: $title,\n body: $body\n }) {\n projectItem {\n id\n }\n }\n }",
- { projectId, title: draftTitle, body: draftBody }
- )
- ).addProjectV2DraftIssue.projectItem.id;
- const fieldsToUpdate = output.fields ? { ...output.fields } : {};
- if (Object.keys(fieldsToUpdate).length > 0) {
- const projectFields = (
- await github.graphql(
- "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }",
- { projectId }
- )
- ).node.fields.nodes;
- for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) {
- const normalizedFieldName = fieldName
- .split(/[\s_-]+/)
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
- .join(" ");
- let valueToSet,
- field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase());
- if (!field)
- if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")))
- try {
- field = (
- await github.graphql(
- "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }",
- { projectId, name: normalizedFieldName, dataType: "TEXT" }
- )
- ).createProjectV2Field.projectV2Field;
- } catch (createError) {
- core.warning(`Failed to create field "${fieldName}": ${createError.message}`);
- continue;
- }
- else
- try {
- field = (
- await github.graphql(
- "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }",
- { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] }
- )
- ).createProjectV2Field.projectV2Field;
- } catch (createError) {
- core.warning(`Failed to create field "${fieldName}": ${createError.message}`);
- continue;
- }
- if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) };
- else if (field.options) {
- let option = field.options.find(o => o.name === fieldValue);
- if (!option)
- try {
- const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }],
- updatedField = (
- await github.graphql(
- "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }",
- { fieldId: field.id, fieldName: field.name, options: allOptions }
- )
- ).updateProjectV2Field.projectV2Field;
- ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField));
- } catch (createError) {
- core.warning(`Failed to create option "${fieldValue}": ${createError.message}`);
- continue;
- }
- if (!option) {
- core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`);
- continue;
- }
- valueToSet = { singleSelectOptionId: option.id };
- } else valueToSet = { text: String(fieldValue) };
- await github.graphql(
- "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }",
- { projectId, itemId, fieldId: field.id, value: valueToSet }
- );
- }
- }
- core.setOutput("item-id", itemId);
- return;
- }
- let contentNumber = null;
- if (hasContentNumber || hasIssue || hasPullRequest) {
- const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request,
- sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim();
- if (sanitizedContentNumber) {
- if (!/^\d+$/.test(sanitizedContentNumber)) throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`);
- contentNumber = Number.parseInt(sanitizedContentNumber, 10);
- } else core.warning("Content number field provided but empty; skipping project item update.");
- }
- if (null !== contentNumber) {
- const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest",
- contentQuery =
- "Issue" === contentType
- ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }"
- : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }",
- contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }),
- contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest,
- contentId = contentData.id,
- createdAt = contentData.createdAt,
- closedAt = contentData.closedAt,
- existingItem = await (async function (projectId, contentId) {
- let hasNextPage = !0,
- endCursor = null;
- for (; hasNextPage; ) {
- const result = await github.graphql(
- "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }",
- { projectId, after: endCursor }
- ),
- found = result.node.items.nodes.find(item => item.content && item.content.id === contentId);
- if (found) return found;
- ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor));
- }
- return null;
- })(projectId, contentId);
- let itemId;
- if (existingItem) ((itemId = existingItem.id), core.info("β Item already on board"));
- else {
- itemId = (
- await github.graphql(
- "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }",
- { projectId, contentId }
- )
- ).addProjectV2ItemById.item.id;
- try {
- await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] });
- } catch (labelError) {
- core.warning(`Failed to add campaign label: ${labelError.message}`);
- }
- }
- const fieldsToUpdate = output.fields ? { ...output.fields } : {};
- if (Object.keys(fieldsToUpdate).length > 0) {
- const projectFields = (
- await github.graphql(
- "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }",
- { projectId }
- )
- ).node.fields.nodes;
- for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) {
- const normalizedFieldName = fieldName
- .split(/[\s_-]+/)
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
- .join(" ");
- let valueToSet,
- field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase());
- if (!field)
- if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")))
- try {
- field = (
- await github.graphql(
- "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }",
- { projectId, name: normalizedFieldName, dataType: "TEXT" }
- )
- ).createProjectV2Field.projectV2Field;
- } catch (createError) {
- core.warning(`Failed to create field "${fieldName}": ${createError.message}`);
- continue;
- }
- else
- try {
- field = (
- await github.graphql(
- "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }",
- { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] }
- )
- ).createProjectV2Field.projectV2Field;
- } catch (createError) {
- core.warning(`Failed to create field "${fieldName}": ${createError.message}`);
- continue;
- }
- if (field.dataType === "DATE") {
- valueToSet = { date: String(fieldValue) };
- } else if (field.options) {
- let option = field.options.find(o => o.name === fieldValue);
- if (!option)
- try {
- const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }],
- updatedField = (
- await github.graphql(
- "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }",
- { fieldId: field.id, fieldName: field.name, options: allOptions }
- )
- ).updateProjectV2Field.projectV2Field;
- ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField));
- } catch (createError) {
- core.warning(`Failed to create option "${fieldValue}": ${createError.message}`);
- continue;
- }
- if (!option) {
- core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`);
- continue;
- }
- valueToSet = { singleSelectOptionId: option.id };
- } else valueToSet = { text: String(fieldValue) };
- await github.graphql(
- "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }",
- { projectId, itemId, fieldId: field.id, value: valueToSet }
- );
- }
- }
- core.setOutput("item-id", itemId);
- }
- } catch (error) {
- if (error.message && error.message.includes("does not have permission to create projects")) {
- const usingCustomToken = !!process.env.GH_AW_PROJECT_GITHUB_TOKEN;
- core.error(
- `Failed to manage project: ${error.message}\n\nTroubleshooting:\n β’ Create the project manually at https://github.com/orgs/${owner}/projects/new.\n β’ Or supply a PAT (classic with project + repo scopes, or fine-grained with Projects: Read+Write) via GH_AW_PROJECT_GITHUB_TOKEN.\n β’ Or use a GitHub App with Projects: Read+Write permission.\n β’ Ensure the workflow grants projects: write.\n\n` +
- (usingCustomToken ? "GH_AW_PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN - this cannot access Projects v2 API. You must configure GH_AW_PROJECT_GITHUB_TOKEN.")
- );
- } else core.error(`Failed to manage project: ${error.message}`);
- throw error;
- }
- }
- async function main() {
- const result = loadAgentOutput();
- if (!result.success) return;
- const updateProjectItems = result.items.filter(item => "update_project" === item.type);
- if (0 !== updateProjectItems.length)
- for (let i = 0; i < updateProjectItems.length; i++) {
- const output = updateProjectItems[i];
- try {
- await updateProject(output);
- } catch (error) {
- (core.error(`Failed to process item ${i + 1}`), logGraphQLError(error, `Processing update_project item ${i + 1}`));
- }
- }
- }
- ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main());
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/update_project.cjs');
+ await main();
diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml
index 8d38563faf..277d3fd5d9 100644
--- a/.github/workflows/changeset.lock.yml
+++ b/.github/workflows/changeset.lock.yml
@@ -6312,6 +6312,15 @@ jobs:
outputs:
push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6323,6 +6332,12 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Download patch artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: aw.patch
+ path: /tmp/gh-aw/
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
@@ -6335,1282 +6350,6 @@ jobs:
permission-contents: write
permission-issues: write
permission-pull-requests: write
- - name: Download patch artifact
- continue-on-error: true
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
- with:
- name: aw.patch
- path: /tmp/gh-aw/
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011'
- // @ts-check
- ///
-
- /**
- * Update the activation comment with a link to the created pull request or issue
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} itemUrl - URL of the created item (pull request or issue)
- * @param {number} itemNumber - Number of the item (pull request or issue)
- * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request")
- */
- async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") {
- const itemLabel = itemType === "issue" ? "issue" : "pull request";
- const linkMessage = itemType === "issue" ? `\n\nβ
Issue created: [#${itemNumber}](${itemUrl})` : `\n\nβ
Pull request created: [#${itemNumber}](${itemUrl})`;
- await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel);
- }
-
- /**
- * Update the activation comment with a commit link
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} commitSha - SHA of the commit
- * @param {string} commitUrl - URL of the commit
- */
- async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) {
- const shortSha = commitSha.substring(0, 7);
- const message = `\n\nβ
Commit pushed: [\`${shortSha}\`](${commitUrl})`;
- await updateActivationCommentWithMessage(github, context, core, message, "commit");
- }
-
- /**
- * Update the activation comment with a custom message
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} message - Message to append to the comment
- * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit")
- */
- async function updateActivationCommentWithMessage(github, context, core, message, label = "") {
- const commentId = process.env.GH_AW_COMMENT_ID;
- const commentRepo = process.env.GH_AW_COMMENT_REPO;
-
- // If no comment was created in activation, skip updating
- if (!commentId) {
- core.info("No activation comment to update (GH_AW_COMMENT_ID not set)");
- return;
- }
-
- core.info(`Updating activation comment ${commentId}`);
-
- // Parse comment repo (format: "owner/repo") with validation
- let repoOwner = context.repo.owner;
- let repoName = context.repo.repo;
- if (commentRepo) {
- const parts = commentRepo.split("/");
- if (parts.length === 2) {
- repoOwner = parts[0];
- repoName = parts[1];
- } else {
- core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`);
- }
- }
-
- core.info(`Updating comment in ${repoOwner}/${repoName}`);
-
- // Check if this is a discussion comment (GraphQL node ID format)
- const isDiscussionComment = commentId.startsWith("DC_");
-
- try {
- if (isDiscussionComment) {
- // Get current comment body using GraphQL
- const currentComment = await github.graphql(
- `
- query($commentId: ID!) {
- node(id: $commentId) {
- ... on DiscussionComment {
- body
- }
- }
- }`,
- { commentId: commentId }
- );
-
- if (!currentComment?.node?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible");
- return;
- }
- const currentBody = currentComment.node.body;
- const updatedBody = currentBody + message;
-
- // Update discussion comment using GraphQL
- const result = await github.graphql(
- `
- mutation($commentId: ID!, $body: String!) {
- updateDiscussionComment(input: { commentId: $commentId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { commentId: commentId, body: updatedBody }
- );
-
- const comment = result.updateDiscussionComment.comment;
- const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment";
- core.info(successMessage);
- core.info(`Comment ID: ${comment.id}`);
- core.info(`Comment URL: ${comment.url}`);
- } else {
- // Get current comment body using REST API
- const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- if (!currentComment?.data?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted");
- return;
- }
- const currentBody = currentComment.data.body;
- const updatedBody = currentBody + message;
-
- // Update issue/PR comment using REST API
- const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- body: updatedBody,
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment";
- core.info(successMessage);
- core.info(`Comment ID: ${response.data.id}`);
- core.info(`Comment URL: ${response.data.html_url}`);
- }
- } catch (error) {
- // Don't fail the workflow if we can't update the comment - just log a warning
- core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`);
- }
- }
-
- module.exports = {
- updateActivationComment,
- updateActivationCommentWithCommit,
- };
-
- EOF_967a5011
- cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_4d21ccbd'
- // @ts-check
- ///
-
- /**
- * Shared context helper functions for update workflows (issues, pull requests, etc.)
- *
- * This module provides reusable functions for determining if we're in a valid
- * context for updating a specific entity type and extracting entity numbers
- * from GitHub event payloads.
- *
- * @module update_context_helpers
- */
-
- /**
- * Check if the current context is a valid issue context
- * @param {string} eventName - GitHub event name
- * @param {any} _payload - GitHub event payload (unused but kept for interface consistency)
- * @returns {boolean} Whether context is valid for issue updates
- */
- function isIssueContext(eventName, _payload) {
- return eventName === "issues" || eventName === "issue_comment";
- }
-
- /**
- * Get issue number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} Issue number or undefined
- */
- function getIssueNumber(payload) {
- return payload?.issue?.number;
- }
-
- /**
- * Check if the current context is a valid pull request context
- * @param {string} eventName - GitHub event name
- * @param {any} payload - GitHub event payload
- * @returns {boolean} Whether context is valid for PR updates
- */
- function isPRContext(eventName, payload) {
- const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target";
-
- // Also check for issue_comment on a PR
- const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request;
-
- return isPR || !!isIssueCommentOnPR;
- }
-
- /**
- * Get pull request number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} PR number or undefined
- */
- function getPRNumber(payload) {
- if (payload?.pull_request) {
- return payload.pull_request.number;
- }
- // For issue_comment events on PRs, the PR number is in issue.number
- if (payload?.issue && payload?.issue?.pull_request) {
- return payload.issue.number;
- }
- return undefined;
- }
-
- /**
- * Check if the current context is a valid discussion context
- * @param {string} eventName - GitHub event name
- * @param {any} _payload - GitHub event payload (unused but kept for interface consistency)
- * @returns {boolean} Whether context is valid for discussion updates
- */
- function isDiscussionContext(eventName, _payload) {
- return eventName === "discussion" || eventName === "discussion_comment";
- }
-
- /**
- * Get discussion number from the context payload
- * @param {any} payload - GitHub event payload
- * @returns {number|undefined} Discussion number or undefined
- */
- function getDiscussionNumber(payload) {
- return payload?.discussion?.number;
- }
-
- module.exports = {
- isIssueContext,
- getIssueNumber,
- isPRContext,
- getPRNumber,
- isDiscussionContext,
- getDiscussionNumber,
- };
-
- EOF_4d21ccbd
- cat > /tmp/gh-aw/scripts/update_pr_description_helpers.cjs << 'EOF_d0693c3b'
- // @ts-check
- ///
-
- /**
- * Helper functions for updating pull request descriptions
- * Handles append, prepend, replace, and replace-island operations
- * @module update_pr_description_helpers
- */
-
- const { getFooterMessage } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
-
- /**
- * Build the AI footer with workflow attribution
- * Uses the messages system to support custom templates from frontmatter
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} AI attribution footer
- */
- function buildAIFooter(workflowName, runUrl) {
- return "\n\n" + getFooterMessage({ workflowName, runUrl });
- }
-
- /**
- * Build the island start marker for replace-island mode
- * @param {number} runId - Workflow run ID
- * @returns {string} Island start marker
- */
- function buildIslandStartMarker(runId) {
- return ``;
- }
-
- /**
- * Build the island end marker for replace-island mode
- * @param {number} runId - Workflow run ID
- * @returns {string} Island end marker
- */
- function buildIslandEndMarker(runId) {
- return ``;
- }
-
- /**
- * Find and extract island content from body
- * @param {string} body - The body content to search
- * @param {number} runId - Workflow run ID
- * @returns {{found: boolean, startIndex: number, endIndex: number}} Island location info
- */
- function findIsland(body, runId) {
- const startMarker = buildIslandStartMarker(runId);
- const endMarker = buildIslandEndMarker(runId);
-
- const startIndex = body.indexOf(startMarker);
- if (startIndex === -1) {
- return { found: false, startIndex: -1, endIndex: -1 };
- }
-
- const endIndex = body.indexOf(endMarker, startIndex);
- if (endIndex === -1) {
- return { found: false, startIndex: -1, endIndex: -1 };
- }
-
- return { found: true, startIndex, endIndex: endIndex + endMarker.length };
- }
-
- /**
- * Update PR body with the specified operation
- * @param {Object} params - Update parameters
- * @param {string} params.currentBody - Current PR body content
- * @param {string} params.newContent - New content to add/replace
- * @param {string} params.operation - Operation type: "append", "prepend", "replace", or "replace-island"
- * @param {string} params.workflowName - Name of the workflow
- * @param {string} params.runUrl - URL of the workflow run
- * @param {number} params.runId - Workflow run ID
- * @returns {string} Updated body content
- */
- function updatePRBody(params) {
- const { currentBody, newContent, operation, workflowName, runUrl, runId } = params;
- const aiFooter = buildAIFooter(workflowName, runUrl);
-
- if (operation === "replace") {
- // Replace: just use the new content as-is
- core.info("Operation: replace (full body replacement)");
- return newContent;
- }
-
- if (operation === "replace-island") {
- // Try to find existing island for this run ID
- const island = findIsland(currentBody, runId);
-
- if (island.found) {
- // Replace the island content
- core.info(`Operation: replace-island (updating existing island for run ${runId})`);
- const startMarker = buildIslandStartMarker(runId);
- const endMarker = buildIslandEndMarker(runId);
- const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`;
-
- const before = currentBody.substring(0, island.startIndex);
- const after = currentBody.substring(island.endIndex);
- return before + islandContent + after;
- } else {
- // Island not found, fall back to append mode
- core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`);
- const startMarker = buildIslandStartMarker(runId);
- const endMarker = buildIslandEndMarker(runId);
- const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`;
- const appendSection = `\n\n---\n\n${islandContent}`;
- return currentBody + appendSection;
- }
- }
-
- if (operation === "prepend") {
- // Prepend: add content, AI footer, and horizontal line at the start
- core.info("Operation: prepend (add to start with separator)");
- const prependSection = `${newContent}${aiFooter}\n\n---\n\n`;
- return prependSection + currentBody;
- }
-
- // Default to append
- core.info("Operation: append (add to end with separator)");
- const appendSection = `\n\n---\n\n${newContent}${aiFooter}`;
- return currentBody + appendSection;
- }
-
- module.exports = {
- buildAIFooter,
- buildIslandStartMarker,
- buildIslandEndMarker,
- findIsland,
- updatePRBody,
- };
-
- EOF_d0693c3b
- cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_5e2e1ea7'
- // @ts-check
- ///
-
- /**
- * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.)
- *
- * This module depends on GitHub Actions environment globals provided by actions/github-script:
- * - core: @actions/core module for logging and outputs
- * - github: @octokit/rest instance for GitHub API calls
- * - context: GitHub Actions context with event payload and repository info
- *
- * @module update_runner
- */
-
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
-
- /**
- * @typedef {Object} UpdateRunnerConfig
- * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request")
- * @property {string} displayName - Human-readable name (e.g., "issue", "pull request")
- * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests")
- * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number")
- * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number")
- * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url")
- * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid
- * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload
- * @property {boolean} supportsStatus - Whether this type supports status updates
- * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace)
- * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview
- * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call
- * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item
- */
-
- /**
- * Resolve the target number for an update operation
- * @param {Object} params - Resolution parameters
- * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number)
- * @param {any} params.item - Update item with optional explicit number field
- * @param {string} params.numberField - Field name for explicit number
- * @param {boolean} params.isValidContext - Whether current context is valid
- * @param {number|undefined} params.contextNumber - Number from triggering context
- * @param {string} params.displayName - Display name for error messages
- * @returns {{success: true, number: number} | {success: false, error: string}}
- */
- function resolveTargetNumber(params) {
- const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params;
-
- if (updateTarget === "*") {
- // For target "*", we need an explicit number from the update item
- const explicitNumber = item[numberField];
- if (explicitNumber) {
- const parsed = parseInt(explicitNumber, 10);
- if (isNaN(parsed) || parsed <= 0) {
- return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` };
- }
- return { success: true, number: parsed };
- } else {
- return { success: false, error: `Target is "*" but no ${numberField} specified in update item` };
- }
- } else if (updateTarget && updateTarget !== "triggering") {
- // Explicit number specified in target
- const parsed = parseInt(updateTarget, 10);
- if (isNaN(parsed) || parsed <= 0) {
- return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` };
- }
- return { success: true, number: parsed };
- } else {
- // Default behavior: use triggering context
- if (isValidContext && contextNumber) {
- return { success: true, number: contextNumber };
- }
- return { success: false, error: `Could not determine ${displayName} number` };
- }
- }
-
- /**
- * Build update data based on allowed fields and provided values
- * @param {Object} params - Build parameters
- * @param {any} params.item - Update item with field values
- * @param {boolean} params.canUpdateStatus - Whether status updates are allowed
- * @param {boolean} params.canUpdateTitle - Whether title updates are allowed
- * @param {boolean} params.canUpdateBody - Whether body updates are allowed
- * @param {boolean} [params.canUpdateLabels] - Whether label updates are allowed
- * @param {boolean} params.supportsStatus - Whether this type supports status
- * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}}
- */
- function buildUpdateData(params) {
- const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, canUpdateLabels, supportsStatus } = params;
-
- /** @type {any} */
- const updateData = {};
- let hasUpdates = false;
- const logMessages = [];
-
- // Handle status update (only for types that support it, like issues)
- if (supportsStatus && canUpdateStatus && item.status !== undefined) {
- if (item.status === "open" || item.status === "closed") {
- updateData.state = item.status;
- hasUpdates = true;
- logMessages.push(`Will update status to: ${item.status}`);
- } else {
- logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`);
- }
- }
-
- // Handle title update
- let titleForDedup = null;
- if (canUpdateTitle && item.title !== undefined) {
- const trimmedTitle = typeof item.title === "string" ? item.title.trim() : "";
- if (trimmedTitle.length > 0) {
- updateData.title = trimmedTitle;
- titleForDedup = trimmedTitle;
- hasUpdates = true;
- logMessages.push(`Will update title to: ${trimmedTitle}`);
- } else {
- logMessages.push("Invalid title value: must be a non-empty string");
- }
- }
-
- // Handle body update (with title deduplication)
- if (canUpdateBody && item.body !== undefined) {
- if (typeof item.body === "string") {
- let processedBody = item.body;
-
- // If we're updating the title at the same time, remove duplicate title from body
- if (titleForDedup) {
- processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody);
- }
-
- updateData.body = processedBody;
- hasUpdates = true;
- logMessages.push(`Will update body (length: ${processedBody.length})`);
- } else {
- logMessages.push("Invalid body value: must be a string");
- }
- }
-
- // Handle labels update
- if (canUpdateLabels && item.labels !== undefined) {
- if (Array.isArray(item.labels)) {
- updateData.labels = item.labels;
- hasUpdates = true;
- logMessages.push(`Will update labels to: ${item.labels.join(", ")}`);
- } else {
- logMessages.push("Invalid labels value: must be an array");
- }
- }
-
- return { hasUpdates, updateData, logMessages };
- }
-
- /**
- * Run the update workflow with the provided configuration
- * @param {UpdateRunnerConfig} config - Configuration for the update runner
- * @returns {Promise} Array of updated items or undefined
- */
- async function runUpdateWorkflow(config) {
- const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config;
-
- // Check if we're in staged mode
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
-
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
-
- // Find all update items
- const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType);
- if (updateItems.length === 0) {
- core.info(`No ${itemType} items found in agent output`);
- return;
- }
-
- core.info(`Found ${updateItems.length} ${itemType} item(s)`);
-
- // If in staged mode, emit step summary instead of updating
- if (isStaged) {
- await generateStagedPreview({
- title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`,
- description: `The following ${displayName} updates would be applied if staged mode was disabled:`,
- items: updateItems,
- renderItem: renderStagedItem,
- });
- return;
- }
-
- // Get the configuration from environment variables
- const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering";
- const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true";
- const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true";
- const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true";
- const canUpdateLabels = process.env.GH_AW_UPDATE_LABELS === "true";
-
- core.info(`Update target configuration: ${updateTarget}`);
- if (supportsStatus) {
- core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`);
- } else {
- core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}, labels: ${canUpdateLabels}`);
- }
-
- // Check context validity
- const contextIsValid = isValidContext(context.eventName, context.payload);
- const contextNumber = getContextNumber(context.payload);
-
- // Validate context based on target configuration
- if (updateTarget === "triggering" && !contextIsValid) {
- core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`);
- return;
- }
-
- const updatedItems = [];
-
- // Process each update item
- for (let i = 0; i < updateItems.length; i++) {
- const updateItem = updateItems[i];
- core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`);
-
- // Resolve target number
- const targetResult = resolveTargetNumber({
- updateTarget,
- item: updateItem,
- numberField,
- isValidContext: contextIsValid,
- contextNumber,
- displayName,
- });
-
- if (!targetResult.success) {
- core.info(targetResult.error);
- continue;
- }
-
- const targetNumber = targetResult.number;
- core.info(`Updating ${displayName} #${targetNumber}`);
-
- // Build update data
- const { hasUpdates, updateData, logMessages } = buildUpdateData({
- item: updateItem,
- canUpdateStatus,
- canUpdateTitle,
- canUpdateBody,
- canUpdateLabels,
- supportsStatus,
- });
-
- // Log all messages
- for (const msg of logMessages) {
- core.info(msg);
- }
-
- // Handle body operation for types that support it (like PRs with append/prepend)
- if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") {
- // The body was already added by buildUpdateData, but we need to handle operations
- // This will be handled by the executeUpdate function for PR-specific logic
- updateData._operation = updateItem.operation || "append";
- updateData._rawBody = updateItem.body;
- }
-
- if (!hasUpdates) {
- core.info("No valid updates to apply for this item");
- continue;
- }
-
- try {
- // Execute the update using the provided function
- const updatedItem = await executeUpdate(github, context, targetNumber, updateData);
- core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`);
- updatedItems.push(updatedItem);
-
- // Set output for the last updated item (for backward compatibility)
- if (i === updateItems.length - 1) {
- core.setOutput(outputNumberKey, updatedItem.number);
- core.setOutput(outputUrlKey, updatedItem.html_url);
- }
- } catch (error) {
- core.error(`β Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
-
- // Write summary for all updated items
- if (updatedItems.length > 0) {
- let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`;
- for (const item of updatedItems) {
- summaryContent += getSummaryLine(item);
- }
- await core.summary.addRaw(summaryContent).write();
- }
-
- core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`);
- return updatedItems;
- }
-
- /**
- * @typedef {Object} RenderStagedItemConfig
- * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request")
- * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number")
- * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:")
- * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request")
- * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates
- */
-
- /**
- * Create a render function for staged preview items
- * @param {RenderStagedItemConfig} config - Configuration for the renderer
- * @returns {(item: any, index: number) => string} Render function
- */
- function createRenderStagedItem(config) {
- const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config;
-
- return function renderStagedItem(item, index) {
- let content = `#### ${entityName} Update ${index + 1}\n`;
- if (item[numberField]) {
- content += `**${targetLabel}** #${item[numberField]}\n\n`;
- } else {
- content += `**Target:** ${currentTargetText}\n\n`;
- }
-
- if (item.title !== undefined) {
- content += `**New Title:** ${item.title}\n\n`;
- }
- if (item.body !== undefined) {
- if (includeOperation) {
- const operation = item.operation || "append";
- content += `**Operation:** ${operation}\n`;
- content += `**Body Content:**\n${item.body}\n\n`;
- } else {
- content += `**New Body:**\n${item.body}\n\n`;
- }
- }
- if (item.status !== undefined) {
- content += `**New Status:** ${item.status}\n\n`;
- }
- return content;
- };
- }
-
- /**
- * @typedef {Object} SummaryLineConfig
- * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR")
- */
-
- /**
- * Create a summary line generator function
- * @param {SummaryLineConfig} config - Configuration for the summary generator
- * @returns {(item: any) => string} Summary line generator function
- */
- function createGetSummaryLine(config) {
- const { entityPrefix } = config;
-
- return function getSummaryLine(item) {
- return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`;
- };
- }
-
- /**
- * @typedef {Object} UpdateHandlerConfig
- * @property {string} itemType - Type of item in agent output (e.g., "update_issue")
- * @property {string} displayName - Human-readable name (e.g., "issue")
- * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues")
- * @property {string} numberField - Field name for explicit number (e.g., "issue_number")
- * @property {string} outputNumberKey - Output key for number (e.g., "issue_number")
- * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url")
- * @property {string} entityName - Display name for entity (e.g., "Issue", "Pull Request")
- * @property {string} entityPrefix - Prefix for summary lines (e.g., "Issue", "PR")
- * @property {string} targetLabel - Label for target in staged preview (e.g., "Target Issue:")
- * @property {string} currentTargetText - Text for current target (e.g., "Current issue")
- * @property {boolean} supportsStatus - Whether this type supports status updates
- * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace)
- * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid
- * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload
- * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call
- */
-
- /**
- * Create an update handler from configuration
- * This factory function eliminates boilerplate by generating all the
- * render functions, summary line generators, and the main handler
- * @param {UpdateHandlerConfig} config - Handler configuration
- * @returns {() => Promise} Main handler function
- */
- function createUpdateHandler(config) {
- // Create render function for staged preview
- const renderStagedItem = createRenderStagedItem({
- entityName: config.entityName,
- numberField: config.numberField,
- targetLabel: config.targetLabel,
- currentTargetText: config.currentTargetText,
- includeOperation: config.supportsOperation,
- });
-
- // Create summary line generator
- const getSummaryLine = createGetSummaryLine({
- entityPrefix: config.entityPrefix,
- });
-
- // Return the main handler function
- return async function main() {
- return await runUpdateWorkflow({
- itemType: config.itemType,
- displayName: config.displayName,
- displayNamePlural: config.displayNamePlural,
- numberField: config.numberField,
- outputNumberKey: config.outputNumberKey,
- outputUrlKey: config.outputUrlKey,
- isValidContext: config.isValidContext,
- getContextNumber: config.getContextNumber,
- supportsStatus: config.supportsStatus,
- supportsOperation: config.supportsOperation,
- renderStagedItem,
- executeUpdate: config.executeUpdate,
- getSummaryLine,
- });
- };
- }
-
- module.exports = {
- runUpdateWorkflow,
- resolveTargetNumber,
- buildUpdateData,
- createRenderStagedItem,
- createGetSummaryLine,
- createUpdateHandler,
- };
-
- EOF_5e2e1ea7
- name: Update Pull Request
id: update_pull_request
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_pull_request'))
@@ -7620,65 +6359,13 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { createUpdateHandler } = require('/tmp/gh-aw/scripts/update_runner.cjs');
- const { updatePRBody } = require('/tmp/gh-aw/scripts/update_pr_description_helpers.cjs');
- const { isPRContext, getPRNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs');
- async function executePRUpdate(github, context, prNumber, updateData) {
- const operation = updateData._operation || "replace";
- const rawBody = updateData._rawBody;
- const { _operation, _rawBody, ...apiData } = updateData;
- if (rawBody !== undefined && operation !== "replace") {
- const { data: currentPR } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- });
- const currentBody = currentPR.body || "";
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow";
- const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- apiData.body = updatePRBody({
- currentBody,
- newContent: rawBody,
- operation,
- workflowName,
- runUrl,
- runId: context.runId,
- });
- core.info(`Will update body (length: ${apiData.body.length})`);
- } else if (rawBody !== undefined) {
- core.info("Operation: replace (full body replacement)");
- }
- const { data: pr } = await github.rest.pulls.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- ...apiData,
- });
- return pr;
- }
- const main = createUpdateHandler({
- itemType: "update_pull_request",
- displayName: "pull request",
- displayNamePlural: "pull requests",
- numberField: "pull_request_number",
- outputNumberKey: "pull_request_number",
- outputUrlKey: "pull_request_url",
- entityName: "Pull Request",
- entityPrefix: "PR",
- targetLabel: "Target PR:",
- currentTargetText: "Current pull request",
- supportsStatus: false,
- supportsOperation: true,
- isValidContext: isPRContext,
- getContextNumber: getPRNumber,
- executeUpdate: executePRUpdate,
- });
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/update_pull_request.cjs');
+ await main();
- name: Checkout repository
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch'))
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
@@ -7710,314 +6397,13 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const fs = require("fs");
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs');
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || "";
- if (agentOutputFile.trim() === "") {
- core.info("Agent output content is empty");
- return;
- }
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return;
- }
- const target = process.env.GH_AW_PUSH_TARGET || "triggering";
- const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn";
- if (!fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const message = "No patch file found - cannot push without changes";
- switch (ifNoChanges) {
- case "error":
- core.setFailed(message);
- return;
- case "ignore":
- return;
- case "warn":
- default:
- core.info(message);
- return;
- }
- }
- const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- if (patchContent.includes("Failed to generate patch")) {
- const message = "Patch file contains error message - cannot push without changes";
- core.error("Patch file generation failed - this is an error condition that requires investigation");
- core.error(`Patch file location: /tmp/gh-aw/aw.patch`);
- core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`);
- const previewLength = Math.min(500, patchContent.length);
- core.error(`Patch file preview (first ${previewLength} characters):`);
- core.error(patchContent.substring(0, previewLength));
- core.setFailed(message);
- return;
- }
- const isEmpty = !patchContent || !patchContent.trim();
- if (!isEmpty) {
- const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10);
- const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
- const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
- core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
- if (patchSizeKb > maxSizeKb) {
- const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
- core.setFailed(message);
- return;
- }
- core.info("Patch size validation passed");
- }
- if (isEmpty) {
- const message = "Patch file is empty - no changes to apply (noop operation)";
- switch (ifNoChanges) {
- case "error":
- core.setFailed("No changes to push - failing as configured by if-no-changes: error");
- return;
- case "ignore":
- break;
- case "warn":
- default:
- core.info(message);
- break;
- }
- }
- core.info(`Agent output content length: ${outputContent.length}`);
- if (!isEmpty) {
- core.info("Patch content validation passed");
- }
- core.info(`Target configuration: ${target}`);
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- return;
- }
- const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch");
- if (!pushItem) {
- core.info("No push-to-pull-request-branch item found in agent output");
- return;
- }
- core.info("Found push-to-pull-request-branch item");
- if (isStaged) {
- await generateStagedPreview({
- title: "Push to PR Branch",
- description: "The following changes would be pushed if staged mode was disabled:",
- items: [{ target, commit_message: pushItem.commit_message }],
- renderItem: item => {
- let content = "";
- content += `**Target:** ${item.target}\n\n`;
- if (item.commit_message) {
- content += `**Commit Message:** ${item.commit_message}\n\n`;
- }
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- if (patchStats.trim()) {
- content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
- content += `Show patch preview
\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n \n\n`;
- } else {
- content += `**Changes:** No changes (empty patch)\n\n`;
- }
- }
- return content;
- },
- });
- return;
- }
- if (target !== "*" && target !== "triggering") {
- const pullNumber = parseInt(target, 10);
- if (isNaN(pullNumber)) {
- core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number');
- return;
- }
- }
- let pullNumber;
- if (target === "triggering") {
- pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number;
- if (!pullNumber) {
- core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context');
- return;
- }
- } else if (target === "*") {
- if (pushItem.pull_number) {
- pullNumber = parseInt(pushItem.pull_number, 10);
- }
- } else {
- pullNumber = parseInt(target, 10);
- }
- let branchName;
- let prTitle = "";
- let prLabels = [];
- if (!pullNumber) {
- core.setFailed("Pull request number is required but not found");
- return;
- }
- try {
- const { data: pullRequest } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: pullNumber,
- });
- branchName = pullRequest.head.ref;
- prTitle = pullRequest.title || "";
- prLabels = pullRequest.labels.map(label => label.name);
- } catch (error) {
- core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`);
- core.setFailed(`Failed to determine branch name for PR ${pullNumber}`);
- return;
- }
- core.info(`Target branch: ${branchName}`);
- core.info(`PR title: ${prTitle}`);
- core.info(`PR labels: ${prLabels.join(", ")}`);
- const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX;
- if (titlePrefix && !prTitle.startsWith(titlePrefix)) {
- core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`);
- return;
- }
- const requiredLabelsStr = process.env.GH_AW_PR_LABELS;
- if (requiredLabelsStr) {
- const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim());
- const missingLabels = requiredLabels.filter(label => !prLabels.includes(label));
- if (missingLabels.length > 0) {
- core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`);
- return;
- }
- }
- if (titlePrefix) {
- core.info(`β Title prefix validation passed: "${titlePrefix}"`);
- }
- if (requiredLabelsStr) {
- core.info(`β Labels validation passed: ${requiredLabelsStr}`);
- }
- const hasChanges = !isEmpty;
- core.info(`Switching to branch: ${branchName}`);
- try {
- core.info(`Fetching branch: ${branchName}`);
- await exec.exec(`git fetch origin ${branchName}:refs/remotes/origin/${branchName}`);
- } catch (fetchError) {
- core.setFailed(`Failed to fetch branch ${branchName}: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
- return;
- }
- try {
- await exec.exec(`git rev-parse --verify origin/${branchName}`);
- } catch (verifyError) {
- core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`);
- return;
- }
- try {
- await exec.exec(`git checkout -B ${branchName} origin/${branchName}`);
- core.info(`Checked out existing branch from origin: ${branchName}`);
- } catch (checkoutError) {
- core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`);
- return;
- }
- if (!isEmpty) {
- core.info("Applying patch...");
- try {
- const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX;
- if (commitTitleSuffix) {
- core.info(`Appending commit title suffix: "${commitTitleSuffix}"`);
- let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`);
- fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8");
- core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`);
- }
- const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- const patchLines = finalPatchContent.split("\n");
- const previewLineCount = Math.min(100, patchLines.length);
- core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
- for (let i = 0; i < previewLineCount; i++) {
- core.info(patchLines[i]);
- }
- await exec.exec("git am /tmp/gh-aw/aw.patch");
- core.info("Patch applied successfully");
- await exec.exec(`git push origin ${branchName}`);
- core.info(`Changes committed and pushed to branch: ${branchName}`);
- } catch (error) {
- core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`);
- try {
- core.info("Investigating patch failure...");
- const statusResult = await exec.getExecOutput("git", ["status"]);
- core.info("Git status output:");
- core.info(statusResult.stdout);
- const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]);
- core.info("Recent commits (last 5):");
- core.info(logResult.stdout);
- const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]);
- core.info("Uncommitted changes:");
- core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)");
- const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
- core.info("Failed patch diff:");
- core.info(patchDiffResult.stdout);
- const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]);
- core.info("Failed patch (full):");
- core.info(patchFullResult.stdout);
- } catch (investigateError) {
- core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`);
- }
- core.setFailed("Failed to apply patch");
- return;
- }
- } else {
- core.info("Skipping patch application (empty patch)");
- const message = "No changes to apply - noop operation completed successfully";
- switch (ifNoChanges) {
- case "error":
- core.setFailed("No changes to apply - failing as configured by if-no-changes: error");
- return;
- case "ignore":
- break;
- case "warn":
- default:
- core.info(message);
- break;
- }
- }
- const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]);
- if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA");
- const commitSha = commitShaRes.stdout.trim();
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- const pushUrl = `${repoUrl}/tree/${branchName}`;
- const commitUrl = `${repoUrl}/commit/${commitSha}`;
- core.setOutput("branch_name", branchName);
- core.setOutput("commit_sha", commitSha);
- core.setOutput("push_url", pushUrl);
- core.setOutput("commit_url", commitUrl);
- if (hasChanges) {
- await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl);
- }
- const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)";
- const summaryContent = hasChanges
- ? `
- ## ${summaryTitle}
- - **Branch**: \`${branchName}\`
- - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl})
- - **URL**: [${pushUrl}](${pushUrl})
- `
- : `
- ## ${summaryTitle}
- - **Branch**: \`${branchName}\`
- - **Status**: No changes to apply (noop operation)
- - **URL**: [${pushUrl}](${pushUrl})
- `;
- await core.summary.addRaw(summaryContent).write();
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/push_to_pull_request_branch.cjs');
+ await main();
- name: Invalidate GitHub App token
if: always() && steps.app-token.outputs.token != ''
env:
diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml
index 7bd582c482..bc7639aac9 100644
--- a/.github/workflows/ci-coach.lock.yml
+++ b/.github/workflows/ci-coach.lock.yml
@@ -6828,6 +6828,15 @@ jobs:
create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }}
create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6845,275 +6854,6 @@ jobs:
with:
name: aw.patch
path: /tmp/gh-aw/
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011'
- // @ts-check
- ///
-
- /**
- * Update the activation comment with a link to the created pull request or issue
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} itemUrl - URL of the created item (pull request or issue)
- * @param {number} itemNumber - Number of the item (pull request or issue)
- * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request")
- */
- async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") {
- const itemLabel = itemType === "issue" ? "issue" : "pull request";
- const linkMessage = itemType === "issue" ? `\n\nβ
Issue created: [#${itemNumber}](${itemUrl})` : `\n\nβ
Pull request created: [#${itemNumber}](${itemUrl})`;
- await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel);
- }
-
- /**
- * Update the activation comment with a commit link
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} commitSha - SHA of the commit
- * @param {string} commitUrl - URL of the commit
- */
- async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) {
- const shortSha = commitSha.substring(0, 7);
- const message = `\n\nβ
Commit pushed: [\`${shortSha}\`](${commitUrl})`;
- await updateActivationCommentWithMessage(github, context, core, message, "commit");
- }
-
- /**
- * Update the activation comment with a custom message
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} message - Message to append to the comment
- * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit")
- */
- async function updateActivationCommentWithMessage(github, context, core, message, label = "") {
- const commentId = process.env.GH_AW_COMMENT_ID;
- const commentRepo = process.env.GH_AW_COMMENT_REPO;
-
- // If no comment was created in activation, skip updating
- if (!commentId) {
- core.info("No activation comment to update (GH_AW_COMMENT_ID not set)");
- return;
- }
-
- core.info(`Updating activation comment ${commentId}`);
-
- // Parse comment repo (format: "owner/repo") with validation
- let repoOwner = context.repo.owner;
- let repoName = context.repo.repo;
- if (commentRepo) {
- const parts = commentRepo.split("/");
- if (parts.length === 2) {
- repoOwner = parts[0];
- repoName = parts[1];
- } else {
- core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`);
- }
- }
-
- core.info(`Updating comment in ${repoOwner}/${repoName}`);
-
- // Check if this is a discussion comment (GraphQL node ID format)
- const isDiscussionComment = commentId.startsWith("DC_");
-
- try {
- if (isDiscussionComment) {
- // Get current comment body using GraphQL
- const currentComment = await github.graphql(
- `
- query($commentId: ID!) {
- node(id: $commentId) {
- ... on DiscussionComment {
- body
- }
- }
- }`,
- { commentId: commentId }
- );
-
- if (!currentComment?.node?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible");
- return;
- }
- const currentBody = currentComment.node.body;
- const updatedBody = currentBody + message;
-
- // Update discussion comment using GraphQL
- const result = await github.graphql(
- `
- mutation($commentId: ID!, $body: String!) {
- updateDiscussionComment(input: { commentId: $commentId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { commentId: commentId, body: updatedBody }
- );
-
- const comment = result.updateDiscussionComment.comment;
- const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment";
- core.info(successMessage);
- core.info(`Comment ID: ${comment.id}`);
- core.info(`Comment URL: ${comment.url}`);
- } else {
- // Get current comment body using REST API
- const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- if (!currentComment?.data?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted");
- return;
- }
- const currentBody = currentComment.data.body;
- const updatedBody = currentBody + message;
-
- // Update issue/PR comment using REST API
- const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- body: updatedBody,
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment";
- core.info(successMessage);
- core.info(`Comment ID: ${response.data.id}`);
- core.info(`Comment URL: ${response.data.html_url}`);
- }
- } catch (error) {
- // Don't fail the workflow if we can't update the comment - just log a warning
- core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`);
- }
- }
-
- module.exports = {
- updateActivationComment,
- updateActivationCommentWithCommit,
- };
-
- EOF_967a5011
- name: Checkout repository
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
@@ -7148,496 +6888,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const fs = require("fs");
- const crypto = require("crypto");
- const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- function generatePatchPreview(patchContent) {
- if (!patchContent || !patchContent.trim()) {
- return "";
- }
- const lines = patchContent.split("\n");
- const maxLines = 500;
- const maxChars = 2000;
- let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n");
- const lineTruncated = lines.length > maxLines;
- const charTruncated = preview.length > maxChars;
- if (charTruncated) {
- preview = preview.slice(0, maxChars);
- }
- const truncated = lineTruncated || charTruncated;
- const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`;
- return `\n\n${summary}
\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n `;
- }
- async function main() {
- core.setOutput("pull_request_number", "");
- core.setOutput("pull_request_url", "");
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("branch_name", "");
- core.setOutput("fallback_used", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const workflowId = process.env.GH_AW_WORKFLOW_ID;
- if (!workflowId) {
- throw new Error("GH_AW_WORKFLOW_ID environment variable is required");
- }
- const baseBranch = process.env.GH_AW_BASE_BRANCH;
- if (!baseBranch) {
- throw new Error("GH_AW_BASE_BRANCH environment variable is required");
- }
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || "";
- let outputContent = "";
- if (agentOutputFile.trim() !== "") {
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- }
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- }
- const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn";
- const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true";
- if (!fs.existsSync("/tmp/gh-aw/aw.patch")) {
- if (allowEmpty) {
- core.info("No patch file found, but allow-empty is enabled - will create empty PR");
- } else {
- const message = "No patch file found - cannot create pull request without changes";
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β οΈ No patch file found\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (no patch file)");
- return;
- }
- switch (ifNoChanges) {
- case "error":
- throw new Error(message);
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- let patchContent = "";
- let isEmpty = true;
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- isEmpty = !patchContent || !patchContent.trim();
- }
- if (patchContent.includes("Failed to generate patch")) {
- if (allowEmpty) {
- core.info("Patch file contains error, but allow-empty is enabled - will create empty PR");
- patchContent = "";
- isEmpty = true;
- } else {
- const message = "Patch file contains error message - cannot create pull request without changes";
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β οΈ Patch file contains error\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (patch error)");
- return;
- }
- switch (ifNoChanges) {
- case "error":
- throw new Error(message);
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- if (!isEmpty) {
- const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10);
- const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
- const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
- core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
- if (patchSizeKb > maxSizeKb) {
- const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β Patch size exceeded\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (patch size error)");
- return;
- }
- throw new Error(message);
- }
- core.info("Patch size validation passed");
- }
- if (isEmpty && !isStaged && !allowEmpty) {
- const message = "Patch file is empty - no changes to apply (noop operation)";
- switch (ifNoChanges) {
- case "error":
- throw new Error("No changes to push - failing as configured by if-no-changes: error");
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- core.info(`Agent output content length: ${outputContent.length}`);
- if (!isEmpty) {
- core.info("Patch content validation passed");
- } else if (allowEmpty) {
- core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)");
- } else {
- core.info("Patch file is empty - processing noop operation");
- }
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.warning("No valid items found in agent output");
- return;
- }
- const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request");
- if (!pullRequestItem) {
- core.warning("No create-pull-request item found in agent output");
- return;
- }
- core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`);
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`;
- summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`;
- summaryContent += `**Base:** ${baseBranch}\n\n`;
- if (pullRequestItem.body) {
- summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`;
- }
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- if (patchStats.trim()) {
- summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
- summaryContent += `Show patch preview
\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n \n\n`;
- } else {
- summaryContent += `**Changes:** No changes (empty patch)\n\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary");
- return;
- }
- let title = pullRequestItem.title.trim();
- let processedBody = pullRequestItem.body;
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null;
- if (!title) {
- title = "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- const labelsEnv = process.env.GH_AW_PR_LABELS;
- const labels = labelsEnv
- ? labelsEnv
- .split(",")
- .map( label => label.trim())
- .filter( label => label)
- : [];
- const draftEnv = process.env.GH_AW_PR_DRAFT;
- const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true;
- core.info(`Creating pull request with title: ${title}`);
- core.info(`Labels: ${JSON.stringify(labels)}`);
- core.info(`Draft: ${draft}`);
- core.info(`Body length: ${body.length}`);
- const randomHex = crypto.randomBytes(8).toString("hex");
- if (!branchName) {
- core.info("No branch name provided in JSONL, generating unique branch name");
- branchName = `${workflowId}-${randomHex}`;
- } else {
- branchName = `${branchName}-${randomHex}`;
- core.info(`Using branch name from JSONL with added salt: ${branchName}`);
- }
- core.info(`Generated branch name: ${branchName}`);
- core.info(`Base branch: ${baseBranch}`);
- core.info(`Fetching base branch: ${baseBranch}`);
- await exec.exec(`git fetch origin ${baseBranch}`);
- try {
- await exec.exec(`git checkout ${baseBranch}`);
- } catch (checkoutError) {
- core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`);
- await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`);
- }
- core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`);
- await exec.exec(`git checkout -b ${branchName}`);
- core.info(`Created new branch from base: ${branchName}`);
- if (!isEmpty) {
- core.info("Applying patch...");
- const patchLines = patchContent.split("\n");
- const previewLineCount = Math.min(500, patchLines.length);
- core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
- for (let i = 0; i < previewLineCount; i++) {
- core.info(patchLines[i]);
- }
- try {
- await exec.exec("git am /tmp/gh-aw/aw.patch");
- core.info("Patch applied successfully");
- } catch (patchError) {
- core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`);
- try {
- core.info("Investigating patch failure...");
- const statusResult = await exec.getExecOutput("git", ["status"]);
- core.info("Git status output:");
- core.info(statusResult.stdout);
- const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
- core.info("Failed patch content:");
- core.info(patchResult.stdout);
- } catch (investigateError) {
- core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`);
- }
- core.setFailed("Failed to apply patch");
- return;
- }
- try {
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
- await exec.exec(`git push origin ${branchName}`);
- core.info("Changes pushed to branch");
- } catch (pushError) {
- core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
- core.warning("Git push operation failed - creating fallback issue instead of pull request");
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- let patchPreview = "";
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- patchPreview = generatePatchPreview(patchContent);
- }
- const fallbackBody = `${body}
- ---
- > [!NOTE]
- > This was originally intended as a pull request, but the git push operation failed.
- >
- > **Workflow Run:** [View run details and download patch artifact](${runUrl})
- >
- > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above.
- To apply the patch locally:
- \`\`\`sh
- # Download the artifact from the workflow run ${runUrl}
- # (Use GitHub MCP tools if gh CLI is not available)
- gh run download ${runId} -n aw.patch
- # Apply the patch
- git am aw.patch
- \`\`\`
- ${patchPreview}`;
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: fallbackBody,
- labels: labels,
- });
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- core.setOutput("branch_name", branchName);
- core.setOutput("fallback_used", "true");
- core.setOutput("push_failed", "true");
- await core.summary
- .addRaw(
- `
- ## Push Failure Fallback
- - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)}
- - **Fallback Issue:** [#${issue.number}](${issue.html_url})
- - **Patch Artifact:** Available in workflow run artifacts
- - **Note:** Push failed, created issue as fallback
- `
- )
- .write();
- return;
- } catch (issueError) {
- core.setFailed(
- `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`
- );
- return;
- }
- }
- } else {
- core.info("Skipping patch application (empty patch)");
- if (allowEmpty) {
- core.info("allow-empty is enabled - will create branch and push with empty commit");
- try {
- await exec.exec(`git commit --allow-empty -m "Initialize"`);
- core.info("Created empty commit");
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
- await exec.exec(`git push origin ${branchName}`);
- core.info("Empty branch pushed successfully");
- } catch (pushError) {
- core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
- return;
- }
- } else {
- const message = "No changes to apply - noop operation completed successfully";
- switch (ifNoChanges) {
- case "error":
- throw new Error("No changes to apply - failing as configured by if-no-changes: error");
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- try {
- const { data: pullRequest } = await github.rest.pulls.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: body,
- head: branchName,
- base: baseBranch,
- draft: draft,
- });
- core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`);
- if (labels.length > 0) {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pullRequest.number,
- labels: labels,
- });
- core.info(`Added labels to pull request: ${JSON.stringify(labels)}`);
- }
- core.setOutput("pull_request_number", pullRequest.number);
- core.setOutput("pull_request_url", pullRequest.html_url);
- core.setOutput("branch_name", branchName);
- await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number);
- await core.summary
- .addRaw(
- `
- ## Pull Request
- - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url})
- - **Branch**: \`${branchName}\`
- - **Base Branch**: \`${baseBranch}\`
- `
- )
- .write();
- } catch (prError) {
- core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`);
- core.info("Falling back to creating an issue instead");
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`;
- let patchPreview = "";
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- patchPreview = generatePatchPreview(patchContent);
- }
- const fallbackBody = `${body}
- ---
- **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}).
- **Original error:** ${prError instanceof Error ? prError.message : String(prError)}
- You can manually create a pull request from the branch if needed.${patchPreview}`;
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: fallbackBody,
- labels: labels,
- });
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- core.setOutput("branch_name", branchName);
- core.setOutput("fallback_used", "true");
- await core.summary
- .addRaw(
- `
- ## Fallback Issue Created
- - **Issue**: [#${issue.number}](${issue.html_url})
- - **Branch**: [\`${branchName}\`](${branchUrl})
- - **Base Branch**: \`${baseBranch}\`
- - **Note**: Pull request creation failed, created issue as fallback
- `
- )
- .write();
- } catch (issueError) {
- core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`);
- return;
- }
- }
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_pull_request.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml
index 80a1004b70..8f7b8cf777 100644
--- a/.github/workflows/ci-doctor.lock.yml
+++ b/.github/workflows/ci-doctor.lock.yml
@@ -6397,6 +6397,15 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6408,944 +6417,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Issue
id: create_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
@@ -7356,295 +6427,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -7657,404 +6446,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml
index a63e88bc86..3195f85d61 100644
--- a/.github/workflows/cli-consistency-checker.lock.yml
+++ b/.github/workflows/cli-consistency-checker.lock.yml
@@ -6121,6 +6121,15 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6132,644 +6141,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Issue
id: create_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
@@ -6781,293 +6152,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml
index 598f44be95..e8409df165 100644
--- a/.github/workflows/cli-version-checker.lock.yml
+++ b/.github/workflows/cli-version-checker.lock.yml
@@ -5966,6 +5966,15 @@ jobs:
create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }}
create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -5977,644 +5986,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e'
- // @ts-check
- /**
- * Sanitize label content for GitHub API
- * Removes control characters, ANSI codes, and neutralizes @mentions
- * @module sanitize_label_content
- */
-
- /**
- * Sanitizes label content by removing control characters, ANSI escape codes,
- * and neutralizing @mentions to prevent unintended notifications.
- *
- * @param {string} content - The label content to sanitize
- * @returns {string} The sanitized label content
- */
- function sanitizeLabelContent(content) {
- if (!content || typeof content !== "string") {
- return "";
- }
- let sanitized = content.trim();
- // Remove ANSI escape sequences FIRST (before removing control chars)
- sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
- // Then remove control characters (except newlines and tabs)
- sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
- sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
- sanitized = sanitized.replace(/[<>&'"]/g, "");
- return sanitized.trim();
- }
-
- module.exports = { sanitizeLabelContent };
-
- EOF_4b431e5e
- cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20'
- // @ts-check
- ///
-
- /**
- * Generate a staged mode preview summary and write it to the step summary.
- *
- * @param {Object} options - Configuration options for the preview
- * @param {string} options.title - The main title for the preview (e.g., "Create Issues")
- * @param {string} options.description - Description of what would happen if staged mode was disabled
- * @param {Array} options.items - Array of items to preview
- * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown
- * @returns {Promise}
- */
- async function generateStagedPreview(options) {
- const { title, description, items, renderItem } = options;
-
- let summaryContent = `## π Staged Mode: ${title} Preview\n\n`;
- summaryContent += `${description}\n\n`;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- summaryContent += renderItem(item, i);
- summaryContent += "---\n\n";
- }
-
- try {
- await core.summary.addRaw(summaryContent).write();
- core.info(summaryContent);
- core.info(`π ${title} preview written to step summary`);
- } catch (error) {
- core.setFailed(error instanceof Error ? error : String(error));
- }
- }
-
- module.exports = { generateStagedPreview };
-
- EOF_8386ee20
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Issue
id: create_issue
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))
@@ -6626,295 +5997,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs');
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function main() {
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("temporary_id_map", "{}");
- core.setOutput("issues_to_assign_copilot", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createIssueItems = result.items.filter(item => item.type === "create_issue");
- if (createIssueItems.length === 0) {
- core.info("No create-issue items found in agent output");
- return;
- }
- core.info(`Found ${createIssueItems.length} create-issue item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (isStaged) {
- await generateStagedPreview({
- title: "Create Issues",
- description: "The following issues would be created if staged mode was disabled:",
- items: createIssueItems,
- renderItem: (item, index) => {
- let content = `#### Issue ${index + 1}\n`;
- content += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.temporary_id) {
- content += `**Temporary ID:** ${item.temporary_id}\n\n`;
- }
- if (item.repo) {
- content += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- content += `**Body:**\n${item.body}\n\n`;
- }
- if (item.labels && item.labels.length > 0) {
- content += `**Labels:** ${item.labels.join(", ")}\n\n`;
- }
- if (item.parent) {
- content += `**Parent:** ${item.parent}\n\n`;
- }
- return content;
- },
- });
- return;
- }
- const parentIssueNumber = context.payload?.issue?.number;
- const temporaryIdMap = new Map();
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const labelsEnv = process.env.GH_AW_ISSUE_LABELS;
- let envLabels = labelsEnv
- ? labelsEnv
- .split(",")
- .map(label => label.trim())
- .filter(label => label)
- : [];
- const createdIssues = [];
- for (let i = 0; i < createIssueItems.length; i++) {
- const createIssueItem = createIssueItems[i];
- const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping issue: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- const temporaryId = createIssueItem.temporary_id || generateTemporaryId();
- core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`);
- core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`);
- core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`);
- let effectiveParentIssueNumber;
- let effectiveParentRepo = itemRepo;
- if (createIssueItem.parent !== undefined) {
- if (isTemporaryId(createIssueItem.parent)) {
- const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent));
- if (resolvedParent !== undefined) {
- effectiveParentIssueNumber = resolvedParent.number;
- effectiveParentRepo = resolvedParent.repo;
- core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- } else {
- core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`);
- effectiveParentIssueNumber = undefined;
- }
- } else {
- effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10);
- if (isNaN(effectiveParentIssueNumber)) {
- core.warning(`Invalid parent value: ${createIssueItem.parent}`);
- effectiveParentIssueNumber = undefined;
- }
- }
- } else {
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- if (itemRepo === contextRepo) {
- effectiveParentIssueNumber = parentIssueNumber;
- }
- }
- core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`);
- if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) {
- core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- let labels = [...envLabels];
- if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) {
- labels = [...labels, ...createIssueItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label)
- .map(label => sanitizeLabelContent(label))
- .filter(label => label)
- .map(label => (label.length > 64 ? label.substring(0, 64) : label))
- .filter((label, index, arr) => arr.indexOf(label) === index);
- let title = createIssueItem.title ? createIssueItem.title.trim() : "";
- let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = createIssueItem.body || "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- if (effectiveParentIssueNumber) {
- core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber);
- if (effectiveParentRepo === itemRepo) {
- bodyLines.push(`Related to #${effectiveParentIssueNumber}`);
- } else {
- bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`);
- }
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue");
- bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating issue in ${itemRepo} with title: ${title}`);
- core.info(`Labels: ${labels}`);
- core.info(`Body length: ${body.length}`);
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- labels: labels,
- });
- core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`);
- createdIssues.push({ ...issue, _repo: itemRepo });
- temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number });
- core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`);
- core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`);
- if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) {
- core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`);
- try {
- core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`);
- const getIssueNodeIdQuery = `
- query($owner: String!, $repo: String!, $issueNumber: Int!) {
- repository(owner: $owner, name: $repo) {
- issue(number: $issueNumber) {
- id
- }
- }
- }
- `;
- const parentResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: effectiveParentIssueNumber,
- });
- const parentNodeId = parentResult.repository.issue.id;
- core.info(`Parent issue node ID: ${parentNodeId}`);
- core.info(`Fetching node ID for child issue #${issue.number}...`);
- const childResult = await github.graphql(getIssueNodeIdQuery, {
- owner: repoParts.owner,
- repo: repoParts.repo,
- issueNumber: issue.number,
- });
- const childNodeId = childResult.repository.issue.id;
- core.info(`Child issue node ID: ${childNodeId}`);
- core.info(`Executing addSubIssue mutation...`);
- const addSubIssueMutation = `
- mutation($issueId: ID!, $subIssueId: ID!) {
- addSubIssue(input: {
- issueId: $issueId,
- subIssueId: $subIssueId
- }) {
- subIssue {
- id
- number
- }
- }
- }
- `;
- await github.graphql(addSubIssueMutation, {
- issueId: parentNodeId,
- subIssueId: childNodeId,
- });
- core.info("β Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber);
- } catch (error) {
- core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`);
- core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`);
- try {
- core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`);
- await github.rest.issues.createComment({
- owner: repoParts.owner,
- repo: repoParts.repo,
- issue_number: effectiveParentIssueNumber,
- body: `Created related issue: #${issue.number}`,
- });
- core.info("β Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)");
- } catch (commentError) {
- core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`);
- }
- }
- } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) {
- core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`);
- } else {
- core.info(`Debug: No parent issue number set, skipping sub-issue linking`);
- }
- if (i === createIssueItems.length - 1) {
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Issues has been disabled in this repository")) {
- core.info(`β Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`);
- core.info("Consider enabling issues in repository settings if you want to create issues automatically");
- continue;
- }
- core.error(`β Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- if (createdIssues.length > 0) {
- let summaryContent = "\n\n## GitHub Issues\n";
- for (const issue of createdIssues) {
- const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : "";
- summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap);
- core.setOutput("temporary_id_map", tempIdMapOutput);
- core.info(`Temporary ID map: ${tempIdMapOutput}`);
- const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
- if (assignCopilot && createdIssues.length > 0) {
- const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(",");
- core.setOutput("issues_to_assign_copilot", issuesToAssign);
- core.info(`Issues to assign copilot: ${issuesToAssign}`);
- }
- core.info(`Successfully created ${createdIssues.length} issue(s)`);
- }
- (async () => {
- await main();
- })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_issue.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml
index 186eb283f8..3211e15d39 100644
--- a/.github/workflows/cloclo.lock.yml
+++ b/.github/workflows/cloclo.lock.yml
@@ -6339,6 +6339,15 @@ jobs:
create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }}
create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6356,852 +6365,6 @@ jobs:
with:
name: aw.patch
path: /tmp/gh-aw/
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6'
- // @ts-check
- ///
-
- /**
- * Footer Message Module
- *
- * This module provides footer and installation instructions generation
- * for safe-output workflows.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} FooterContext
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref)
- * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source
- * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow
- */
-
- /**
- * Get the footer message, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer message
- */
- function getFooterMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default footer template - pirate themed! π΄ββ οΈ
- const defaultFooter = "> Ahoy! This treasure was crafted by [π΄ββ οΈ {workflow_name}]({run_url})";
-
- // Use custom footer if configured
- let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext);
-
- // Add triggering reference if available
- if (ctx.triggeringNumber) {
- footer += ` fer issue #{triggering_number} πΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber));
- }
-
- return footer;
- }
-
- /**
- * Get the footer installation instructions, using custom template if configured.
- * @param {FooterContext} ctx - Context for footer generation
- * @returns {string} Footer installation message or empty string if no source
- */
- function getFooterInstallMessage(ctx) {
- if (!ctx.workflowSource || !ctx.workflowSourceUrl) {
- return "";
- }
-
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default installation template - pirate themed! π΄ββ οΈ
- const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [π¦ {workflow_source_url}]({workflow_source_url})!";
-
- // Use custom installation message if configured
- return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
- }
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * The marker format is:
- *
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate the complete footer with AI attribution and optional installation instructions.
- * This is a drop-in replacement for the original generateFooter function.
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Complete footer text
- */
- function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- // Determine triggering number (issue takes precedence, then PR, then discussion)
- let triggeringNumber;
- if (triggeringIssueNumber) {
- triggeringNumber = triggeringIssueNumber;
- } else if (triggeringPRNumber) {
- triggeringNumber = triggeringPRNumber;
- } else if (triggeringDiscussionNumber) {
- triggeringNumber = `discussion #${triggeringDiscussionNumber}`;
- }
-
- const ctx = {
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceUrl: workflowSourceURL,
- triggeringNumber,
- };
-
- let footer = "\n\n" + getFooterMessage(ctx);
-
- // Add installation instructions if source is available
- const installMessage = getFooterInstallMessage(ctx);
- if (installMessage) {
- footer += "\n>\n" + installMessage;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- getFooterMessage,
- getFooterInstallMessage,
- generateFooterWithMessages,
- generateXMLMarker,
- };
-
- EOF_c14886c6
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011'
- // @ts-check
- ///
-
- /**
- * Update the activation comment with a link to the created pull request or issue
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} itemUrl - URL of the created item (pull request or issue)
- * @param {number} itemNumber - Number of the item (pull request or issue)
- * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request")
- */
- async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") {
- const itemLabel = itemType === "issue" ? "issue" : "pull request";
- const linkMessage = itemType === "issue" ? `\n\nβ
Issue created: [#${itemNumber}](${itemUrl})` : `\n\nβ
Pull request created: [#${itemNumber}](${itemUrl})`;
- await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel);
- }
-
- /**
- * Update the activation comment with a commit link
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} commitSha - SHA of the commit
- * @param {string} commitUrl - URL of the commit
- */
- async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) {
- const shortSha = commitSha.substring(0, 7);
- const message = `\n\nβ
Commit pushed: [\`${shortSha}\`](${commitUrl})`;
- await updateActivationCommentWithMessage(github, context, core, message, "commit");
- }
-
- /**
- * Update the activation comment with a custom message
- * @param {any} github - GitHub REST API instance
- * @param {any} context - GitHub Actions context
- * @param {any} core - GitHub Actions core
- * @param {string} message - Message to append to the comment
- * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit")
- */
- async function updateActivationCommentWithMessage(github, context, core, message, label = "") {
- const commentId = process.env.GH_AW_COMMENT_ID;
- const commentRepo = process.env.GH_AW_COMMENT_REPO;
-
- // If no comment was created in activation, skip updating
- if (!commentId) {
- core.info("No activation comment to update (GH_AW_COMMENT_ID not set)");
- return;
- }
-
- core.info(`Updating activation comment ${commentId}`);
-
- // Parse comment repo (format: "owner/repo") with validation
- let repoOwner = context.repo.owner;
- let repoName = context.repo.repo;
- if (commentRepo) {
- const parts = commentRepo.split("/");
- if (parts.length === 2) {
- repoOwner = parts[0];
- repoName = parts[1];
- } else {
- core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`);
- }
- }
-
- core.info(`Updating comment in ${repoOwner}/${repoName}`);
-
- // Check if this is a discussion comment (GraphQL node ID format)
- const isDiscussionComment = commentId.startsWith("DC_");
-
- try {
- if (isDiscussionComment) {
- // Get current comment body using GraphQL
- const currentComment = await github.graphql(
- `
- query($commentId: ID!) {
- node(id: $commentId) {
- ... on DiscussionComment {
- body
- }
- }
- }`,
- { commentId: commentId }
- );
-
- if (!currentComment?.node?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible");
- return;
- }
- const currentBody = currentComment.node.body;
- const updatedBody = currentBody + message;
-
- // Update discussion comment using GraphQL
- const result = await github.graphql(
- `
- mutation($commentId: ID!, $body: String!) {
- updateDiscussionComment(input: { commentId: $commentId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { commentId: commentId, body: updatedBody }
- );
-
- const comment = result.updateDiscussionComment.comment;
- const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment";
- core.info(successMessage);
- core.info(`Comment ID: ${comment.id}`);
- core.info(`Comment URL: ${comment.url}`);
- } else {
- // Get current comment body using REST API
- const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- if (!currentComment?.data?.body) {
- core.warning("Unable to fetch current comment body, comment may have been deleted");
- return;
- }
- const currentBody = currentComment.data.body;
- const updatedBody = currentBody + message;
-
- // Update issue/PR comment using REST API
- const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
- owner: repoOwner,
- repo: repoName,
- comment_id: parseInt(commentId, 10),
- body: updatedBody,
- headers: {
- Accept: "application/vnd.github+json",
- },
- });
-
- const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment";
- core.info(successMessage);
- core.info(`Comment ID: ${response.data.id}`);
- core.info(`Comment URL: ${response.data.html_url}`);
- }
- } catch (error) {
- // Don't fail the workflow if we can't update the comment - just log a warning
- core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`);
- }
- }
-
- module.exports = {
- updateActivationComment,
- updateActivationCommentWithCommit,
- };
-
- EOF_967a5011
- name: Checkout repository
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
@@ -7239,496 +6402,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const fs = require("fs");
- const crypto = require("crypto");
- const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- function generatePatchPreview(patchContent) {
- if (!patchContent || !patchContent.trim()) {
- return "";
- }
- const lines = patchContent.split("\n");
- const maxLines = 500;
- const maxChars = 2000;
- let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n");
- const lineTruncated = lines.length > maxLines;
- const charTruncated = preview.length > maxChars;
- if (charTruncated) {
- preview = preview.slice(0, maxChars);
- }
- const truncated = lineTruncated || charTruncated;
- const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`;
- return `\n\n${summary}
\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n `;
- }
- async function main() {
- core.setOutput("pull_request_number", "");
- core.setOutput("pull_request_url", "");
- core.setOutput("issue_number", "");
- core.setOutput("issue_url", "");
- core.setOutput("branch_name", "");
- core.setOutput("fallback_used", "");
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const workflowId = process.env.GH_AW_WORKFLOW_ID;
- if (!workflowId) {
- throw new Error("GH_AW_WORKFLOW_ID environment variable is required");
- }
- const baseBranch = process.env.GH_AW_BASE_BRANCH;
- if (!baseBranch) {
- throw new Error("GH_AW_BASE_BRANCH environment variable is required");
- }
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || "";
- let outputContent = "";
- if (agentOutputFile.trim() !== "") {
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- }
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- }
- const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn";
- const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true";
- if (!fs.existsSync("/tmp/gh-aw/aw.patch")) {
- if (allowEmpty) {
- core.info("No patch file found, but allow-empty is enabled - will create empty PR");
- } else {
- const message = "No patch file found - cannot create pull request without changes";
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β οΈ No patch file found\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (no patch file)");
- return;
- }
- switch (ifNoChanges) {
- case "error":
- throw new Error(message);
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- let patchContent = "";
- let isEmpty = true;
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- isEmpty = !patchContent || !patchContent.trim();
- }
- if (patchContent.includes("Failed to generate patch")) {
- if (allowEmpty) {
- core.info("Patch file contains error, but allow-empty is enabled - will create empty PR");
- patchContent = "";
- isEmpty = true;
- } else {
- const message = "Patch file contains error message - cannot create pull request without changes";
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β οΈ Patch file contains error\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (patch error)");
- return;
- }
- switch (ifNoChanges) {
- case "error":
- throw new Error(message);
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- if (!isEmpty) {
- const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10);
- const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
- const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
- core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
- if (patchSizeKb > maxSizeKb) {
- const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** β Patch size exceeded\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary (patch size error)");
- return;
- }
- throw new Error(message);
- }
- core.info("Patch size validation passed");
- }
- if (isEmpty && !isStaged && !allowEmpty) {
- const message = "Patch file is empty - no changes to apply (noop operation)";
- switch (ifNoChanges) {
- case "error":
- throw new Error("No changes to push - failing as configured by if-no-changes: error");
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- core.info(`Agent output content length: ${outputContent.length}`);
- if (!isEmpty) {
- core.info("Patch content validation passed");
- } else if (allowEmpty) {
- core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)");
- } else {
- core.info("Patch file is empty - processing noop operation");
- }
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`);
- return;
- }
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.warning("No valid items found in agent output");
- return;
- }
- const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request");
- if (!pullRequestItem) {
- core.warning("No create-pull-request item found in agent output");
- return;
- }
- core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`);
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`;
- summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`;
- summaryContent += `**Base:** ${baseBranch}\n\n`;
- if (pullRequestItem.body) {
- summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`;
- }
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- if (patchStats.trim()) {
- summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
- summaryContent += `Show patch preview
\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n \n\n`;
- } else {
- summaryContent += `**Changes:** No changes (empty patch)\n\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Pull request creation preview written to step summary");
- return;
- }
- let title = pullRequestItem.title.trim();
- let processedBody = pullRequestItem.body;
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null;
- if (!title) {
- title = "Agent Output";
- }
- const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX;
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- const labelsEnv = process.env.GH_AW_PR_LABELS;
- const labels = labelsEnv
- ? labelsEnv
- .split(",")
- .map( label => label.trim())
- .filter( label => label)
- : [];
- const draftEnv = process.env.GH_AW_PR_DRAFT;
- const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true;
- core.info(`Creating pull request with title: ${title}`);
- core.info(`Labels: ${JSON.stringify(labels)}`);
- core.info(`Draft: ${draft}`);
- core.info(`Body length: ${body.length}`);
- const randomHex = crypto.randomBytes(8).toString("hex");
- if (!branchName) {
- core.info("No branch name provided in JSONL, generating unique branch name");
- branchName = `${workflowId}-${randomHex}`;
- } else {
- branchName = `${branchName}-${randomHex}`;
- core.info(`Using branch name from JSONL with added salt: ${branchName}`);
- }
- core.info(`Generated branch name: ${branchName}`);
- core.info(`Base branch: ${baseBranch}`);
- core.info(`Fetching base branch: ${baseBranch}`);
- await exec.exec(`git fetch origin ${baseBranch}`);
- try {
- await exec.exec(`git checkout ${baseBranch}`);
- } catch (checkoutError) {
- core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`);
- await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`);
- }
- core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`);
- await exec.exec(`git checkout -b ${branchName}`);
- core.info(`Created new branch from base: ${branchName}`);
- if (!isEmpty) {
- core.info("Applying patch...");
- const patchLines = patchContent.split("\n");
- const previewLineCount = Math.min(500, patchLines.length);
- core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
- for (let i = 0; i < previewLineCount; i++) {
- core.info(patchLines[i]);
- }
- try {
- await exec.exec("git am /tmp/gh-aw/aw.patch");
- core.info("Patch applied successfully");
- } catch (patchError) {
- core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`);
- try {
- core.info("Investigating patch failure...");
- const statusResult = await exec.getExecOutput("git", ["status"]);
- core.info("Git status output:");
- core.info(statusResult.stdout);
- const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
- core.info("Failed patch content:");
- core.info(patchResult.stdout);
- } catch (investigateError) {
- core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`);
- }
- core.setFailed("Failed to apply patch");
- return;
- }
- try {
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
- await exec.exec(`git push origin ${branchName}`);
- core.info("Changes pushed to branch");
- } catch (pushError) {
- core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
- core.warning("Git push operation failed - creating fallback issue instead of pull request");
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- let patchPreview = "";
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- patchPreview = generatePatchPreview(patchContent);
- }
- const fallbackBody = `${body}
- ---
- > [!NOTE]
- > This was originally intended as a pull request, but the git push operation failed.
- >
- > **Workflow Run:** [View run details and download patch artifact](${runUrl})
- >
- > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above.
- To apply the patch locally:
- \`\`\`sh
- # Download the artifact from the workflow run ${runUrl}
- # (Use GitHub MCP tools if gh CLI is not available)
- gh run download ${runId} -n aw.patch
- # Apply the patch
- git am aw.patch
- \`\`\`
- ${patchPreview}`;
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: fallbackBody,
- labels: labels,
- });
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- core.setOutput("branch_name", branchName);
- core.setOutput("fallback_used", "true");
- core.setOutput("push_failed", "true");
- await core.summary
- .addRaw(
- `
- ## Push Failure Fallback
- - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)}
- - **Fallback Issue:** [#${issue.number}](${issue.html_url})
- - **Patch Artifact:** Available in workflow run artifacts
- - **Note:** Push failed, created issue as fallback
- `
- )
- .write();
- return;
- } catch (issueError) {
- core.setFailed(
- `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`
- );
- return;
- }
- }
- } else {
- core.info("Skipping patch application (empty patch)");
- if (allowEmpty) {
- core.info("allow-empty is enabled - will create branch and push with empty commit");
- try {
- await exec.exec(`git commit --allow-empty -m "Initialize"`);
- core.info("Created empty commit");
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
- await exec.exec(`git push origin ${branchName}`);
- core.info("Empty branch pushed successfully");
- } catch (pushError) {
- core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
- return;
- }
- } else {
- const message = "No changes to apply - noop operation completed successfully";
- switch (ifNoChanges) {
- case "error":
- throw new Error("No changes to apply - failing as configured by if-no-changes: error");
- case "ignore":
- return;
- case "warn":
- default:
- core.warning(message);
- return;
- }
- }
- }
- try {
- const { data: pullRequest } = await github.rest.pulls.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: body,
- head: branchName,
- base: baseBranch,
- draft: draft,
- });
- core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`);
- if (labels.length > 0) {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pullRequest.number,
- labels: labels,
- });
- core.info(`Added labels to pull request: ${JSON.stringify(labels)}`);
- }
- core.setOutput("pull_request_number", pullRequest.number);
- core.setOutput("pull_request_url", pullRequest.html_url);
- core.setOutput("branch_name", branchName);
- await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number);
- await core.summary
- .addRaw(
- `
- ## Pull Request
- - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url})
- - **Branch**: \`${branchName}\`
- - **Base Branch**: \`${baseBranch}\`
- `
- )
- .write();
- } catch (prError) {
- core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`);
- core.info("Falling back to creating an issue instead");
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`;
- let patchPreview = "";
- if (fs.existsSync("/tmp/gh-aw/aw.patch")) {
- const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8");
- patchPreview = generatePatchPreview(patchContent);
- }
- const fallbackBody = `${body}
- ---
- **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}).
- **Original error:** ${prError instanceof Error ? prError.message : String(prError)}
- You can manually create a pull request from the branch if needed.${patchPreview}`;
- try {
- const { data: issue } = await github.rest.issues.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: title,
- body: fallbackBody,
- labels: labels,
- });
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- core.setOutput("issue_number", issue.number);
- core.setOutput("issue_url", issue.html_url);
- core.setOutput("branch_name", branchName);
- core.setOutput("fallback_used", "true");
- await core.summary
- .addRaw(
- `
- ## Fallback Issue Created
- - **Issue**: [#${issue.number}](${issue.html_url})
- - **Branch**: [\`${branchName}\`](${branchUrl})
- - **Base Branch**: \`${baseBranch}\`
- - **Note**: Pull request creation failed, created issue as fallback
- `
- )
- .write();
- } catch (issueError) {
- core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`);
- return;
- }
- }
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_pull_request.cjs');
+ await main();
- name: Add Comment
id: add_comment
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment'))
@@ -7740,404 +6420,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- async function minimizeComment(github, nodeId, reason = "outdated") {
- const query = `
- mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) {
- minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) {
- minimizedComment {
- isMinimized
- }
- }
- }
- `;
- const result = await github.graphql(query, { nodeId, classifier: reason });
- return {
- id: nodeId,
- isMinimized: result.minimizeComment.minimizedComment.isMinimized,
- };
- }
- async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) {
- const comments = [];
- let page = 1;
- const perPage = 100;
- while (true) {
- const { data } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: issueNumber,
- per_page: perPage,
- page,
- });
- if (data.length === 0) {
- break;
- }
- const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body }));
- comments.push(...filteredComments);
- if (data.length < perPage) {
- break;
- }
- page++;
- }
- return comments;
- }
- async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) {
- const query = `
- query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- comments(first: 100, after: $cursor) {
- nodes {
- id
- body
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- `;
- const comments = [];
- let cursor = null;
- while (true) {
- const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor });
- if (!result.repository?.discussion?.comments?.nodes) {
- break;
- }
- const filteredComments = result.repository.discussion.comments.nodes
- .filter(comment => comment.body?.includes(``) && !comment.body.includes(``))
- .map(({ id, body }) => ({ id, body }));
- comments.push(...filteredComments);
- if (!result.repository.discussion.comments.pageInfo.hasNextPage) {
- break;
- }
- cursor = result.repository.discussion.comments.pageInfo.endCursor;
- }
- return comments;
- }
- async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) {
- if (!workflowId) {
- core.info("No workflow ID available, skipping hide-older-comments");
- return 0;
- }
- const normalizedReason = reason.toUpperCase();
- if (allowedReasons && allowedReasons.length > 0) {
- const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase());
- if (!normalizedAllowedReasons.includes(normalizedReason)) {
- core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`);
- return 0;
- }
- }
- core.info(`Searching for previous comments with workflow ID: ${workflowId}`);
- let comments;
- if (isDiscussion) {
- comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- } else {
- comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId);
- }
- if (comments.length === 0) {
- core.info("No previous comments found with matching workflow ID");
- return 0;
- }
- core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`);
- let hiddenCount = 0;
- for (const comment of comments) {
- const nodeId = isDiscussion ? String(comment.id) : comment.node_id;
- core.info(`Hiding comment: ${nodeId}`);
- const result = await minimizeComment(github, nodeId, normalizedReason);
- hiddenCount++;
- core.info(`β Hidden comment: ${nodeId}`);
- }
- core.info(`Successfully hidden ${hiddenCount} comment(s)`);
- return hiddenCount;
- }
- async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- const discussionId = repository.discussion.id;
- const discussionUrl = repository.discussion.url;
- const mutation = replyToId
- ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`
- : `mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- body
- createdAt
- url
- }
- }
- }`;
- const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message };
- const result = await github.graphql(mutation, variables);
- const comment = result.addDiscussionComment.comment;
- return {
- id: comment.id,
- html_url: comment.url,
- discussion_url: discussionUrl,
- };
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true";
- const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true";
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const commentItems = result.items.filter( item => item.type === "add_comment");
- if (commentItems.length === 0) {
- core.info("No add-comment items found in agent output");
- return;
- }
- core.info(`Found ${commentItems.length} add-comment item(s)`);
- function getTargetNumber(item) {
- return item.item_number;
- }
- const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
- core.info(`Comment target configuration: ${commentTarget}`);
- const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- const isDiscussion = isDiscussionContext || isDiscussionExplicit;
- const workflowId = process.env.GITHUB_WORKFLOW || "";
- const allowedReasons = process.env.GH_AW_ALLOWED_REASONS
- ? (() => {
- try {
- const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS);
- core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`);
- return parsed;
- } catch (error) {
- core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- })()
- : null;
- if (hideOlderCommentsEnabled) {
- core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`);
- }
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Add Comments Preview\n\n";
- summaryContent += "The following comments would be added if staged mode was disabled:\n\n";
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) {
- summaryContent += "#### Related Items\n\n";
- if (createdIssueUrl && createdIssueNumber) {
- summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`;
- }
- if (createdDiscussionUrl && createdDiscussionNumber) {
- summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`;
- }
- if (createdPullRequestUrl && createdPullRequestNumber) {
- summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`;
- }
- summaryContent += "\n";
- }
- for (let i = 0; i < commentItems.length; i++) {
- const item = commentItems[i];
- summaryContent += `### Comment ${i + 1}\n`;
- const targetNumber = getTargetNumber(item);
- if (targetNumber) {
- const repoUrl = getRepositoryUrl();
- if (isDiscussion) {
- const discussionUrl = `${repoUrl}/discussions/${targetNumber}`;
- summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`;
- } else {
- const issueUrl = `${repoUrl}/issues/${targetNumber}`;
- summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`;
- }
- } else {
- if (isDiscussion) {
- summaryContent += `**Target:** Current discussion\n\n`;
- } else {
- summaryContent += `**Target:** Current issue/PR\n\n`;
- }
- }
- summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`;
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Comment creation preview written to step summary");
- return;
- }
- if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- return;
- }
- const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const createdComments = [];
- for (let i = 0; i < commentItems.length; i++) {
- const commentItem = commentItems[i];
- core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`);
- let itemNumber;
- let commentEndpoint;
- if (commentTarget === "*") {
- const targetNumber = getTargetNumber(commentItem);
- if (targetNumber) {
- itemNumber = parseInt(targetNumber, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number specified: ${targetNumber}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- core.info(`Target is "*" but no number specified in comment item`);
- continue;
- }
- } else if (commentTarget && commentTarget !== "triggering") {
- itemNumber = parseInt(commentTarget, 10);
- if (isNaN(itemNumber) || itemNumber <= 0) {
- core.info(`Invalid target number in target configuration: ${commentTarget}`);
- continue;
- }
- commentEndpoint = isDiscussion ? "discussions" : "issues";
- } else {
- if (isIssueContext) {
- itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number;
- if (context.payload.issue) {
- commentEndpoint = "issues";
- } else {
- core.info("Issue context detected but no issue found in payload");
- continue;
- }
- } else if (isPRContext) {
- itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number;
- if (context.payload.pull_request) {
- commentEndpoint = "issues";
- } else {
- core.info("Pull request context detected but no pull request found in payload");
- continue;
- }
- } else if (isDiscussionContext) {
- itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number;
- if (context.payload.discussion) {
- commentEndpoint = "discussions";
- } else {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- }
- }
- if (!itemNumber) {
- core.info("Could not determine issue, pull request, or discussion number");
- continue;
- }
- let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap);
- const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL;
- const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER;
- const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL;
- const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- const references = [
- createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`,
- createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`,
- createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`,
- ].filter(Boolean);
- if (references.length > 0) {
- body += `\n\n#### Related Items\n\n${references.join("\n")}\n`;
- }
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- if (workflowId) {
- body += `\n\n`;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- body += trackerIDComment;
- }
- body += `\n\n`;
- body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
- if (hideOlderCommentsEnabled && workflowId) {
- core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
- }
- let comment;
- if (commentEndpoint === "discussions") {
- core.info(`Creating comment on discussion #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined;
- if (replyToId) {
- core.info(`Creating threaded reply to comment ${replyToId}`);
- }
- comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId);
- core.info("Created discussion comment #" + comment.id + ": " + comment.html_url);
- comment.discussion_url = comment.discussion_url;
- } else {
- core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const { data: restComment } = await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: itemNumber,
- body: body,
- });
- comment = restComment;
- core.info("Created comment #" + comment.id + ": " + comment.html_url);
- }
- createdComments.push(comment);
- if (i === commentItems.length - 1) {
- core.setOutput("comment_id", comment.id);
- core.setOutput("comment_url", comment.html_url);
- }
- }
- if (createdComments.length > 0) {
- const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n");
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdComments.length} comment(s)`);
- return createdComments;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/add_comment.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/close-old-discussions.lock.yml b/.github/workflows/close-old-discussions.lock.yml
index 3a5fcf65ff..a5be68e4c2 100644
--- a/.github/workflows/close-old-discussions.lock.yml
+++ b/.github/workflows/close-old-discussions.lock.yml
@@ -6052,6 +6052,15 @@ jobs:
GH_AW_WORKFLOW_ID: "close-old-discussions"
GH_AW_WORKFLOW_NAME: "Close Outdated Discussions"
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6063,256 +6072,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4'
- // @ts-check
- ///
-
- /**
- * Generates an XML comment marker with agentic workflow metadata for traceability.
- * This marker enables searching and tracing back items generated by an agentic workflow.
- *
- * Note: This function is duplicated in messages_footer.cjs. While normally we would
- * consolidate to a shared module, importing messages_footer.cjs here would cause the
- * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in
- * a warning message, breaking tests that check for env var declarations.
- *
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {string} XML comment marker with workflow metadata
- */
- function generateXMLMarker(workflowName, runUrl) {
- // Read engine metadata from environment variables
- const engineId = process.env.GH_AW_ENGINE_ID || "";
- const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
- const engineModel = process.env.GH_AW_ENGINE_MODEL || "";
- const trackerId = process.env.GH_AW_TRACKER_ID || "";
-
- // Build the key-value pairs for the marker
- const parts = [];
-
- // Always include agentic-workflow name
- parts.push(`agentic-workflow: ${workflowName}`);
-
- // Add tracker-id if available (for searchability and tracing)
- if (trackerId) {
- parts.push(`tracker-id: ${trackerId}`);
- }
-
- // Add engine ID if available
- if (engineId) {
- parts.push(`engine: ${engineId}`);
- }
-
- // Add version if available
- if (engineVersion) {
- parts.push(`version: ${engineVersion}`);
- }
-
- // Add model if available
- if (engineModel) {
- parts.push(`model: ${engineModel}`);
- }
-
- // Always include run URL
- parts.push(`run: ${runUrl}`);
-
- // Return the XML comment marker
- return ``;
- }
-
- /**
- * Generate footer with AI attribution and workflow installation instructions
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref)
- * @param {string} workflowSourceURL - GitHub URL for the workflow source
- * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow
- * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow
- * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow
- * @returns {string} Footer text
- */
- function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
- let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`;
-
- // Add reference to triggering issue/PR/discussion if available
- if (triggeringIssueNumber) {
- footer += ` for #${triggeringIssueNumber}`;
- } else if (triggeringPRNumber) {
- footer += ` for #${triggeringPRNumber}`;
- } else if (triggeringDiscussionNumber) {
- footer += ` for discussion #${triggeringDiscussionNumber}`;
- }
-
- if (workflowSource && workflowSourceURL) {
- footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`;
- }
-
- // Add XML comment marker for traceability
- footer += "\n\n" + generateXMLMarker(workflowName, runUrl);
-
- footer += "\n";
- return footer;
- }
-
- module.exports = {
- generateFooter,
- generateXMLMarker,
- };
-
- EOF_88f9d2d4
- cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42'
- // @ts-check
- ///
-
- /**
- * Get the repository URL for different purposes
- * This helper handles trial mode where target repository URLs are different from execution context
- * @returns {string} Repository URL
- */
- function getRepositoryUrl() {
- // For trial mode, use target repository for issue/PR URLs but execution context for action runs
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
-
- if (targetRepoSlug) {
- // Use target repository for issue/PR URLs in trial mode
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${targetRepoSlug}`;
- } else if (context.payload.repository?.html_url) {
- // Use execution context repository (default behavior)
- return context.payload.repository.html_url;
- } else {
- // Final fallback for action runs when context repo is not available
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- return `${githubServer}/${context.repo.owner}/${context.repo.repo}`;
- }
- }
-
- module.exports = {
- getRepositoryUrl,
- };
-
- EOF_75ff5f42
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- name: Close Discussion
id: close_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_discussion'))
@@ -6322,233 +6081,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs');
- async function getDiscussionDetails(github, owner, repo, discussionNumber) {
- const { repository } = await github.graphql(
- `
- query($owner: String!, $repo: String!, $num: Int!) {
- repository(owner: $owner, name: $repo) {
- discussion(number: $num) {
- id
- title
- category {
- name
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- url
- }
- }
- }`,
- { owner, repo, num: discussionNumber }
- );
- if (!repository || !repository.discussion) {
- throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`);
- }
- return repository.discussion;
- }
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
- return result.addDiscussionComment.comment;
- }
- async function closeDiscussion(github, discussionId, reason) {
- const mutation = reason
- ? `
- mutation($dId: ID!, $reason: DiscussionCloseReason!) {
- closeDiscussion(input: { discussionId: $dId, reason: $reason }) {
- discussion {
- id
- url
- }
- }
- }`
- : `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId }) {
- discussion {
- id
- url
- }
- }
- }`;
- const variables = reason ? { dId: discussionId, reason } : { dId: discussionId };
- const result = await github.graphql(mutation, variables);
- return result.closeDiscussion.discussion;
- }
- async function main() {
- const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const closeDiscussionItems = result.items.filter( item => item.type === "close_discussion");
- if (closeDiscussionItems.length === 0) {
- core.info("No close-discussion items found in agent output");
- return;
- }
- core.info(`Found ${closeDiscussionItems.length} close-discussion item(s)`);
- const requiredLabels = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS ? process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS.split(",").map(l => l.trim()) : [];
- const requiredTitlePrefix = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_TITLE_PREFIX || "";
- const requiredCategory = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_CATEGORY || "";
- const target = process.env.GH_AW_CLOSE_DISCUSSION_TARGET || "triggering";
- core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, requiredCategory=${requiredCategory}, target=${target}`);
- const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
- if (isStaged) {
- let summaryContent = "## π Staged Mode: Close Discussions Preview\n\n";
- summaryContent += "The following discussions would be closed if staged mode was disabled:\n\n";
- for (let i = 0; i < closeDiscussionItems.length; i++) {
- const item = closeDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- const discussionNumber = item.discussion_number;
- if (discussionNumber) {
- const repoUrl = getRepositoryUrl();
- const discussionUrl = `${repoUrl}/discussions/${discussionNumber}`;
- summaryContent += `**Target Discussion:** [#${discussionNumber}](${discussionUrl})\n\n`;
- } else {
- summaryContent += `**Target:** Current discussion\n\n`;
- }
- if (item.reason) {
- summaryContent += `**Reason:** ${item.reason}\n\n`;
- }
- summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`;
- if (requiredLabels.length > 0) {
- summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`;
- }
- if (requiredTitlePrefix) {
- summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`;
- }
- if (requiredCategory) {
- summaryContent += `**Required Category:** ${requiredCategory}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion close preview written to step summary");
- return;
- }
- if (target === "triggering" && !isDiscussionContext) {
- core.info('Target is "triggering" but not running in discussion context, skipping discussion close');
- return;
- }
- const triggeringDiscussionNumber = context.payload?.discussion?.number;
- const closedDiscussions = [];
- for (let i = 0; i < closeDiscussionItems.length; i++) {
- const item = closeDiscussionItems[i];
- core.info(`Processing close-discussion item ${i + 1}/${closeDiscussionItems.length}: bodyLength=${item.body.length}`);
- let discussionNumber;
- if (target === "*") {
- const targetNumber = item.discussion_number;
- if (targetNumber) {
- discussionNumber = parseInt(targetNumber, 10);
- if (isNaN(discussionNumber) || discussionNumber <= 0) {
- core.info(`Invalid discussion number specified: ${targetNumber}`);
- continue;
- }
- } else {
- core.info(`Target is "*" but no discussion_number specified in close-discussion item`);
- continue;
- }
- } else if (target && target !== "triggering") {
- discussionNumber = parseInt(target, 10);
- if (isNaN(discussionNumber) || discussionNumber <= 0) {
- core.info(`Invalid discussion number in target configuration: ${target}`);
- continue;
- }
- } else {
- if (isDiscussionContext) {
- discussionNumber = context.payload.discussion?.number;
- if (!discussionNumber) {
- core.info("Discussion context detected but no discussion found in payload");
- continue;
- }
- } else {
- core.info("Not in discussion context and no explicit target specified");
- continue;
- }
- }
- try {
- const discussion = await getDiscussionDetails(github, context.repo.owner, context.repo.repo, discussionNumber);
- if (requiredLabels.length > 0) {
- const discussionLabels = discussion.labels.nodes.map(l => l.name);
- const hasRequiredLabel = requiredLabels.some(required => discussionLabels.includes(required));
- if (!hasRequiredLabel) {
- core.info(`Discussion #${discussionNumber} does not have required labels: ${requiredLabels.join(", ")}`);
- continue;
- }
- }
- if (requiredTitlePrefix && !discussion.title.startsWith(requiredTitlePrefix)) {
- core.info(`Discussion #${discussionNumber} does not have required title prefix: ${requiredTitlePrefix}`);
- continue;
- }
- if (requiredCategory && discussion.category.name !== requiredCategory) {
- core.info(`Discussion #${discussionNumber} is not in required category: ${requiredCategory}`);
- continue;
- }
- let body = item.body.trim();
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- body += getTrackerID("markdown");
- body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, undefined, triggeringDiscussionNumber);
- core.info(`Adding comment to discussion #${discussionNumber}`);
- core.info(`Comment content length: ${body.length}`);
- const comment = await addDiscussionComment(github, discussion.id, body);
- core.info("Added discussion comment: " + comment.url);
- core.info(`Closing discussion #${discussionNumber} with reason: ${item.reason || "none"}`);
- const closedDiscussion = await closeDiscussion(github, discussion.id, item.reason);
- core.info("Closed discussion: " + closedDiscussion.url);
- closedDiscussions.push({
- number: discussionNumber,
- url: discussion.url,
- comment_url: comment.url,
- });
- if (i === closeDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussionNumber);
- core.setOutput("discussion_url", discussion.url);
- core.setOutput("comment_url", comment.url);
- }
- } catch (error) {
- core.error(`β Failed to close discussion #${discussionNumber}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (closedDiscussions.length > 0) {
- let summaryContent = "\n\n## Closed Discussions\n";
- for (const discussion of closedDiscussions) {
- summaryContent += `- Discussion #${discussion.number}: [View Discussion](${discussion.url})\n`;
- summaryContent += ` - Comment: [View Comment](${discussion.comment_url})\n`;
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully closed ${closedDiscussions.length} discussion(s)`);
- return closedDiscussions;
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/close_discussion.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml
index 1377a52e90..5440a2fa83 100644
--- a/.github/workflows/commit-changes-analyzer.lock.yml
+++ b/.github/workflows/commit-changes-analyzer.lock.yml
@@ -5812,6 +5812,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -5823,887 +5832,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -6713,279 +5841,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml
index 65fe32cd1b..b6d4a24ea6 100644
--- a/.github/workflows/copilot-agent-analysis.lock.yml
+++ b/.github/workflows/copilot-agent-analysis.lock.yml
@@ -6209,6 +6209,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6220,887 +6229,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -7110,281 +6238,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml
index 0c24ceb0c9..9dc317274e 100644
--- a/.github/workflows/copilot-pr-merged-report.lock.yml
+++ b/.github/workflows/copilot-pr-merged-report.lock.yml
@@ -7439,6 +7439,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -7450,887 +7459,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -8340,279 +7468,11 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml
index f72ee544cb..ce81b81522 100644
--- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml
+++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml
@@ -6857,6 +6857,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6868,887 +6877,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise>} List of closed discussions
- */
- async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) {
- // Build search criteria description for logging
- const searchCriteria = [];
- if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
- if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
- core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`);
-
- const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number);
-
- if (olderDiscussions.length === 0) {
- core.info("No older discussions found to close");
- return [];
- }
-
- core.info(`Found ${olderDiscussions.length} older discussion(s) to close`);
-
- // Limit to MAX_CLOSE_COUNT discussions
- const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT);
-
- if (olderDiscussions.length > MAX_CLOSE_COUNT) {
- core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`);
- }
-
- const closedDiscussions = [];
-
- for (let i = 0; i < discussionsToClose.length; i++) {
- const discussion = discussionsToClose[i];
- try {
- // Generate closing message using the messages module
- const closingMessage = getCloseOlderDiscussionMessage({
- newDiscussionUrl: newDiscussion.url,
- newDiscussionNumber: newDiscussion.number,
- workflowName,
- runUrl,
- });
-
- // Add comment first
- core.info(`Adding closing comment to discussion #${discussion.number}`);
- await addDiscussionComment(github, discussion.id, closingMessage);
-
- // Then close the discussion as outdated
- core.info(`Closing discussion #${discussion.number} as outdated`);
- await closeDiscussionAsOutdated(github, discussion.id);
-
- closedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- });
-
- core.info(`β Closed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`β Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- return closedDiscussions;
- }
-
- module.exports = {
- closeOlderDiscussions,
- searchOlderDiscussions,
- addDiscussionComment,
- closeDiscussionAsOutdated,
- MAX_CLOSE_COUNT,
- GRAPHQL_DELAY_MS,
- };
-
- EOF_1a84cdd3
- cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070'
- // @ts-check
- ///
-
- /**
- * Add expiration XML comment to body lines if expires is set
- * @param {string[]} bodyLines - Array of body lines to append to
- * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES")
- * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request")
- * @returns {void}
- */
- function addExpirationComment(bodyLines, envVarName, entityType) {
- const expiresEnv = process.env[envVarName];
- if (expiresEnv) {
- const expiresDays = parseInt(expiresEnv, 10);
- if (!isNaN(expiresDays) && expiresDays > 0) {
- const expirationDate = new Date();
- expirationDate.setDate(expirationDate.getDate() + expiresDays);
- const expirationISO = expirationDate.toISOString();
- bodyLines.push(``);
- core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`);
- }
- }
- }
-
- module.exports = {
- addExpirationComment,
- };
-
- EOF_33eff070
- cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250'
- // @ts-check
- ///
-
- /**
- * Get tracker-id from environment variable, log it, and optionally format it
- * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value
- * @returns {string} Tracker ID in requested format or empty string
- */
- function getTrackerID(format) {
- const trackerID = process.env.GH_AW_TRACKER_ID || "";
- if (trackerID) {
- core.info(`Tracker ID: ${trackerID}`);
- return format === "markdown" ? `\n\n` : trackerID;
- }
- return "";
- }
-
- module.exports = {
- getTrackerID,
- };
-
- EOF_bfad4250
- cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f'
- // @ts-check
- ///
-
- const fs = require("fs");
-
- /**
- * Maximum content length to log for debugging purposes
- * @type {number}
- */
- const MAX_LOG_CONTENT_LENGTH = 10000;
-
- /**
- * Truncate content for logging if it exceeds the maximum length
- * @param {string} content - Content to potentially truncate
- * @returns {string} Truncated content with indicator if truncated
- */
- function truncateForLogging(content) {
- if (content.length <= MAX_LOG_CONTENT_LENGTH) {
- return content;
- }
- return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`;
- }
-
- /**
- * Load and parse agent output from the GH_AW_AGENT_OUTPUT file
- *
- * This utility handles the common pattern of:
- * 1. Reading the GH_AW_AGENT_OUTPUT environment variable
- * 2. Loading the file content
- * 3. Validating the JSON structure
- * 4. Returning parsed items array
- *
- * @returns {{
- * success: true,
- * items: any[]
- * } | {
- * success: false,
- * items?: undefined,
- * error?: string
- * }} Result object with success flag and items array (if successful) or error message
- */
- function loadAgentOutput() {
- const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
-
- // No agent output file specified
- if (!agentOutputFile) {
- core.info("No GH_AW_AGENT_OUTPUT environment variable found");
- return { success: false };
- }
-
- // Read agent output from file
- let outputContent;
- try {
- outputContent = fs.readFileSync(agentOutputFile, "utf8");
- } catch (error) {
- const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- return { success: false, error: errorMessage };
- }
-
- // Check for empty content
- if (outputContent.trim() === "") {
- core.info("Agent output content is empty");
- return { success: false };
- }
-
- core.info(`Agent output content length: ${outputContent.length}`);
-
- // Parse the validated output JSON
- let validatedOutput;
- try {
- validatedOutput = JSON.parse(outputContent);
- } catch (error) {
- const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`;
- core.error(errorMessage);
- core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`);
- return { success: false, error: errorMessage };
- }
-
- // Validate items array exists
- if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
- core.info("No valid items found in agent output");
- core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`);
- return { success: false };
- }
-
- return { success: true, items: validatedOutput.items };
- }
-
- module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH };
-
- EOF_b93f537f
- cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89'
- // @ts-check
- ///
-
- /**
- * Close Discussion Message Module
- *
- * This module provides the message for closing older discussions
- * when a newer one is created.
- */
-
- const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs');
-
- /**
- * @typedef {Object} CloseOlderDiscussionContext
- * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one
- * @property {number} newDiscussionNumber - Number of the new discussion
- * @property {string} workflowName - Name of the workflow
- * @property {string} runUrl - URL of the workflow run
- */
-
- /**
- * Get the close-older-discussion message, using custom template if configured.
- * @param {CloseOlderDiscussionContext} ctx - Context for message generation
- * @returns {string} Close older discussion message
- */
- function getCloseOlderDiscussionMessage(ctx) {
- const messages = getMessages();
-
- // Create context with both camelCase and snake_case keys
- const templateContext = toSnakeCase(ctx);
-
- // Default close-older-discussion template - pirate themed! π΄ββ οΈ
- const defaultMessage = `β Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}).
-
- πΊοΈ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**.
-
- Fair winds, matey! π΄ββ οΈ`;
-
- // Use custom message if configured
- return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext);
- }
-
- module.exports = {
- getCloseOlderDiscussionMessage,
- };
-
- EOF_2b835e89
- cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0'
- // @ts-check
- ///
-
- /**
- * Core Message Utilities Module
- *
- * This module provides shared utilities for message template processing.
- * It includes configuration parsing and template rendering functions.
- *
- * Supported placeholders:
- * - {workflow_name} - Name of the workflow
- * - {run_url} - URL to the workflow run
- * - {workflow_source} - Source specification (owner/repo/path@ref)
- * - {workflow_source_url} - GitHub URL for the workflow source
- * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow
- * - {operation} - Operation name (for staged mode titles/descriptions)
- * - {event_type} - Event type description (for run-started messages)
- * - {status} - Workflow status text (for run-failure messages)
- *
- * Both camelCase and snake_case placeholder formats are supported.
- */
-
- /**
- * @typedef {Object} SafeOutputMessages
- * @property {string} [footer] - Custom footer message template
- * @property {string} [footerInstall] - Custom installation instructions template
- * @property {string} [stagedTitle] - Custom staged mode title template
- * @property {string} [stagedDescription] - Custom staged mode description template
- * @property {string} [runStarted] - Custom workflow activation message template
- * @property {string} [runSuccess] - Custom workflow success message template
- * @property {string} [runFailure] - Custom workflow failure message template
- * @property {string} [detectionFailure] - Custom detection job failure message template
- * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
- */
-
- /**
- * Get the safe-output messages configuration from environment variable.
- * @returns {SafeOutputMessages|null} Parsed messages config or null if not set
- */
- function getMessages() {
- const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- if (!messagesEnv) {
- return null;
- }
-
- try {
- // Parse JSON with camelCase keys from Go struct (using json struct tags)
- return JSON.parse(messagesEnv);
- } catch (error) {
- core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`);
- return null;
- }
- }
-
- /**
- * Replace placeholders in a template string with values from context.
- * Supports {key} syntax for placeholder replacement.
- * @param {string} template - Template string with {key} placeholders
- * @param {Record} context - Key-value pairs for replacement
- * @returns {string} Template with placeholders replaced
- */
- function renderTemplate(template, context) {
- return template.replace(/\{(\w+)\}/g, (match, key) => {
- const value = context[key];
- return value !== undefined && value !== null ? String(value) : match;
- });
- }
-
- /**
- * Convert context object keys to snake_case for template rendering
- * @param {Record} obj - Object with camelCase keys
- * @returns {Record} Object with snake_case keys
- */
- function toSnakeCase(obj) {
- /** @type {Record} */
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- // Convert camelCase to snake_case
- const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
- result[snakeKey] = value;
- // Also keep original key for backwards compatibility
- result[key] = value;
- }
- return result;
- }
-
- module.exports = {
- getMessages,
- renderTemplate,
- toSnakeCase,
- };
-
- EOF_6cdb27e0
- cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126'
- // @ts-check
- /**
- * Remove duplicate title from description
- * @module remove_duplicate_title
- */
-
- /**
- * Removes duplicate title from the beginning of description content.
- * If the description starts with a header (# or ## or ### etc.) that matches
- * the title, it will be removed along with any trailing newlines.
- *
- * @param {string} title - The title text to match and remove
- * @param {string} description - The description content that may contain duplicate title
- * @returns {string} The description with duplicate title removed
- */
- function removeDuplicateTitleFromDescription(title, description) {
- // Handle null/undefined/empty inputs
- if (!title || typeof title !== "string") {
- return description || "";
- }
- if (!description || typeof description !== "string") {
- return "";
- }
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
-
- if (!trimmedTitle || !trimmedDescription) {
- return trimmedDescription;
- }
-
- // Match any header level (# to ######) followed by the title at the start
- // This regex matches:
- // - Start of string
- // - One or more # characters
- // - One or more spaces
- // - The exact title (escaped for regex special chars)
- // - Optional trailing spaces
- // - Optional newlines after the header
- const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i");
-
- if (headerRegex.test(trimmedDescription)) {
- return trimmedDescription.replace(headerRegex, "").trim();
- }
-
- return trimmedDescription;
- }
-
- module.exports = { removeDuplicateTitleFromDescription };
-
- EOF_bb4a8126
- cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f'
- // @ts-check
- ///
-
- /**
- * Repository-related helper functions for safe-output scripts
- * Provides common repository parsing, validation, and resolution logic
- */
-
- /**
- * Parse the allowed repos from environment variable
- * @returns {Set} Set of allowed repository slugs
- */
- function parseAllowedRepos() {
- const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS;
- const set = new Set();
- if (allowedReposEnv) {
- allowedReposEnv
- .split(",")
- .map(repo => repo.trim())
- .filter(repo => repo)
- .forEach(repo => set.add(repo));
- }
- return set;
- }
-
- /**
- * Get the default target repository
- * @returns {string} Repository slug in "owner/repo" format
- */
- function getDefaultTargetRepo() {
- // First check if there's a target-repo override
- const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG;
- if (targetRepoSlug) {
- return targetRepoSlug;
- }
- // Fall back to context repo
- return `${context.repo.owner}/${context.repo.repo}`;
- }
-
- /**
- * Validate that a repo is allowed for operations
- * @param {string} repo - Repository slug to validate
- * @param {string} defaultRepo - Default target repository
- * @param {Set} allowedRepos - Set of explicitly allowed repos
- * @returns {{valid: boolean, error: string|null}}
- */
- function validateRepo(repo, defaultRepo, allowedRepos) {
- // Default repo is always allowed
- if (repo === defaultRepo) {
- return { valid: true, error: null };
- }
- // Check if it's in the allowed repos list
- if (allowedRepos.has(repo)) {
- return { valid: true, error: null };
- }
- return {
- valid: false,
- error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`,
- };
- }
-
- /**
- * Parse owner and repo from a repository slug
- * @param {string} repoSlug - Repository slug in "owner/repo" format
- * @returns {{owner: string, repo: string}|null}
- */
- function parseRepoSlug(repoSlug) {
- const parts = repoSlug.split("/");
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- module.exports = {
- parseAllowedRepos,
- getDefaultTargetRepo,
- validateRepo,
- parseRepoSlug,
- };
-
- EOF_0e3d051f
- cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa'
- // @ts-check
- ///
-
- const crypto = require("crypto");
-
- /**
- * Regex pattern for matching temporary ID references in text
- * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters)
- */
- const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi;
-
- /**
- * @typedef {Object} RepoIssuePair
- * @property {string} repo - Repository slug in "owner/repo" format
- * @property {number} number - Issue or discussion number
- */
-
- /**
- * Generate a temporary ID with aw_ prefix for temporary issue IDs
- * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters)
- */
- function generateTemporaryId() {
- return "aw_" + crypto.randomBytes(6).toString("hex");
- }
-
- /**
- * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string)
- * @param {any} value - The value to check
- * @returns {boolean} True if the value is a valid temporary ID
- */
- function isTemporaryId(value) {
- if (typeof value === "string") {
- return /^aw_[0-9a-f]{12}$/i.test(value);
- }
- return false;
- }
-
- /**
- * Normalize a temporary ID to lowercase for consistent map lookups
- * @param {string} tempId - The temporary ID to normalize
- * @returns {string} Lowercase temporary ID
- */
- function normalizeTemporaryId(tempId) {
- return String(tempId).toLowerCase();
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers
- * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo)
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @param {string} [currentRepo] - Current repository slug for same-repo references
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const resolved = tempIdMap.get(normalizeTemporaryId(tempId));
- if (resolved !== undefined) {
- // If we have a currentRepo and the issue is in the same repo, use short format
- if (currentRepo && resolved.repo === currentRepo) {
- return `#${resolved.number}`;
- }
- // Otherwise use full repo#number format for cross-repo references
- return `${resolved.repo}#${resolved.number}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Replace temporary ID references in text with actual issue numbers (legacy format)
- * This is a compatibility function that works with Map
- * Format: #aw_XXXXXXXXXXXX -> #123
- * @param {string} text - The text to process
- * @param {Map} tempIdMap - Map of temporary_id to issue number
- * @returns {string} Text with temporary IDs replaced with issue numbers
- */
- function replaceTemporaryIdReferencesLegacy(text, tempIdMap) {
- return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => {
- const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId));
- if (issueNumber !== undefined) {
- return `#${issueNumber}`;
- }
- // Return original if not found (it may be created later)
- return match;
- });
- }
-
- /**
- * Load the temporary ID map from environment variable
- * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number})
- * @returns {Map} Map of temporary_id to {repo, number}
- */
- function loadTemporaryIdMap() {
- const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP;
- if (!mapJson || mapJson === "{}") {
- return new Map();
- }
- try {
- const mapObject = JSON.parse(mapJson);
- /** @type {Map} */
- const result = new Map();
-
- for (const [key, value] of Object.entries(mapObject)) {
- const normalizedKey = normalizeTemporaryId(key);
- if (typeof value === "number") {
- // Legacy format: number only, use context repo
- const contextRepo = `${context.repo.owner}/${context.repo.repo}`;
- result.set(normalizedKey, { repo: contextRepo, number: value });
- } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) {
- // New format: {repo, number}
- result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) });
- }
- }
- return result;
- } catch (error) {
- if (typeof core !== "undefined") {
- core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`);
- }
- return new Map();
- }
- }
-
- /**
- * Resolve an issue number that may be a temporary ID or an actual issue number
- * Returns structured result with the resolved number, repo, and metadata
- * @param {any} value - The value to resolve (can be temporary ID, number, or string)
- * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number}
- * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}}
- */
- function resolveIssueNumber(value, temporaryIdMap) {
- if (value === undefined || value === null) {
- return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" };
- }
-
- // Check if it's a temporary ID
- const valueStr = String(value);
- if (isTemporaryId(valueStr)) {
- const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr));
- if (resolvedPair !== undefined) {
- return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null };
- }
- return {
- resolved: null,
- wasTemporaryId: true,
- errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`,
- };
- }
-
- // It's a real issue number - use context repo as default
- const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10);
- if (isNaN(issueNumber) || issueNumber <= 0) {
- return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` };
- }
-
- const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : "";
- return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null };
- }
-
- /**
- * Serialize the temporary ID map to JSON for output
- * @param {Map} tempIdMap - Map of temporary_id to {repo, number}
- * @returns {string} JSON string of the map
- */
- function serializeTemporaryIdMap(tempIdMap) {
- const obj = Object.fromEntries(tempIdMap);
- return JSON.stringify(obj);
- }
-
- module.exports = {
- TEMPORARY_ID_PATTERN,
- generateTemporaryId,
- isTemporaryId,
- normalizeTemporaryId,
- replaceTemporaryIdReferences,
- replaceTemporaryIdReferencesLegacy,
- loadTemporaryIdMap,
- resolveIssueNumber,
- serializeTemporaryIdMap,
- };
-
- EOF_795429aa
- name: Create Discussion
id: create_discussion
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion'))
@@ -7758,281 +6886,13 @@ jobs:
with:
github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
- globalThis.github = github;
- globalThis.context = context;
- globalThis.core = core;
- globalThis.exec = exec;
- globalThis.io = io;
- const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs');
- const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs');
- const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs');
- const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs');
- const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs');
- const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs');
- const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs');
- async function fetchRepoDiscussionInfo(owner, repo) {
- const repositoryQuery = `
- query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- id
- discussionCategories(first: 20) {
- nodes {
- id
- name
- slug
- description
- }
- }
- }
- }
- `;
- const queryResult = await github.graphql(repositoryQuery, {
- owner: owner,
- repo: repo,
- });
- if (!queryResult || !queryResult.repository) {
- return null;
- }
- return {
- repositoryId: queryResult.repository.id,
- discussionCategories: queryResult.repository.discussionCategories.nodes || [],
- };
- }
- function resolveCategoryId(categoryConfig, itemCategory, categories) {
- const categoryToMatch = itemCategory || categoryConfig;
- if (categoryToMatch) {
- const categoryById = categories.find(cat => cat.id === categoryToMatch);
- if (categoryById) {
- return { id: categoryById.id, matchType: "id", name: categoryById.name };
- }
- const categoryByName = categories.find(cat => cat.name === categoryToMatch);
- if (categoryByName) {
- return { id: categoryByName.id, matchType: "name", name: categoryByName.name };
- }
- const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch);
- if (categoryBySlug) {
- return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name };
- }
- }
- if (categories.length > 0) {
- return {
- id: categories[0].id,
- matchType: "fallback",
- name: categories[0].name,
- requestedCategory: categoryToMatch,
- };
- }
- return undefined;
- }
- async function main() {
- core.setOutput("discussion_number", "");
- core.setOutput("discussion_url", "");
- const temporaryIdMap = loadTemporaryIdMap();
- if (temporaryIdMap.size > 0) {
- core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`);
- }
- const result = loadAgentOutput();
- if (!result.success) {
- return;
- }
- const createDiscussionItems = result.items.filter(item => item.type === "create_discussion");
- if (createDiscussionItems.length === 0) {
- core.warning("No create-discussion items found in agent output");
- return;
- }
- core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`);
- const allowedRepos = parseAllowedRepos();
- const defaultTargetRepo = getDefaultTargetRepo();
- core.info(`Default target repo: ${defaultTargetRepo}`);
- if (allowedRepos.size > 0) {
- core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
- }
- if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") {
- let summaryContent = "## π Staged Mode: Create Discussions Preview\n\n";
- summaryContent += "The following discussions would be created if staged mode was disabled:\n\n";
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const item = createDiscussionItems[i];
- summaryContent += `### Discussion ${i + 1}\n`;
- summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`;
- if (item.repo) {
- summaryContent += `**Repository:** ${item.repo}\n\n`;
- }
- if (item.body) {
- summaryContent += `**Body:**\n${item.body}\n\n`;
- }
- if (item.category) {
- summaryContent += `**Category:** ${item.category}\n\n`;
- }
- summaryContent += "---\n\n";
- }
- await core.summary.addRaw(summaryContent).write();
- core.info("π Discussion creation preview written to step summary");
- return;
- }
- const repoInfoCache = new Map();
- const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true";
- const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || "";
- const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || "";
- const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || "";
- const labels = labelsEnvVar
- ? labelsEnvVar
- .split(",")
- .map(l => l.trim())
- .filter(l => l.length > 0)
- : [];
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const runId = context.runId;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
- const createdDiscussions = [];
- const closedDiscussionsSummary = [];
- for (let i = 0; i < createDiscussionItems.length; i++) {
- const createDiscussionItem = createDiscussionItems[i];
- const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo;
- const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
- if (!repoValidation.valid) {
- core.warning(`Skipping discussion: ${repoValidation.error}`);
- continue;
- }
- const repoParts = parseRepoSlug(itemRepo);
- if (!repoParts) {
- core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`);
- continue;
- }
- let repoInfo = repoInfoCache.get(itemRepo);
- if (!repoInfo) {
- try {
- const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo);
- if (!fetchedInfo) {
- core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`);
- continue;
- }
- repoInfo = fetchedInfo;
- repoInfoCache.set(itemRepo, repoInfo);
- core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) {
- core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`);
- continue;
- }
- core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`);
- throw error;
- }
- }
- const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories);
- if (!categoryInfo) {
- core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`);
- continue;
- }
- if (categoryInfo.matchType === "name") {
- core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "slug") {
- core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`);
- } else if (categoryInfo.matchType === "fallback") {
- if (categoryInfo.requestedCategory) {
- const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", ");
- core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`);
- core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`);
- } else {
- core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`);
- }
- }
- const categoryId = categoryInfo.id;
- core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`);
- let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : "";
- const bodyText = createDiscussionItem.body || "";
- let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo);
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
- let bodyLines = processedBody.split("\n");
- if (!title) {
- title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output";
- }
- if (titlePrefix && !title.startsWith(titlePrefix)) {
- title = titlePrefix + title;
- }
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
- addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion");
- bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, "");
- const body = bodyLines.join("\n").trim();
- core.info(`Creating discussion in ${itemRepo} with title: ${title}`);
- core.info(`Category ID: ${categoryId}`);
- core.info(`Body length: ${body.length}`);
- try {
- const createDiscussionMutation = `
- mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
- createDiscussion(input: {
- repositoryId: $repositoryId,
- categoryId: $categoryId,
- title: $title,
- body: $body
- }) {
- discussion {
- id
- number
- title
- url
- }
- }
- }
- `;
- const mutationResult = await github.graphql(createDiscussionMutation, {
- repositoryId: repoInfo.repositoryId,
- categoryId: categoryId,
- title: title,
- body: body,
- });
- const discussion = mutationResult.createDiscussion.discussion;
- if (!discussion) {
- core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`);
- continue;
- }
- core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`);
- createdDiscussions.push({ ...discussion, _repo: itemRepo });
- if (i === createDiscussionItems.length - 1) {
- core.setOutput("discussion_number", discussion.number);
- core.setOutput("discussion_url", discussion.url);
- }
- const hasMatchingCriteria = titlePrefix || labels.length > 0;
- if (closeOlderEnabled && hasMatchingCriteria) {
- core.info("close-older-discussions is enabled, searching for older discussions to close...");
- try {
- const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl);
- if (closedDiscussions.length > 0) {
- closedDiscussionsSummary.push(...closedDiscussions);
- core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`);
- }
- } catch (closeError) {
- core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
- }
- } else if (closeOlderEnabled && !hasMatchingCriteria) {
- core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions");
- }
- } catch (error) {
- core.error(`β Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`);
- throw error;
- }
- }
- if (createdDiscussions.length > 0) {
- let summaryContent = "\n\n## GitHub Discussions\n";
- for (const discussion of createdDiscussions) {
- const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : "";
- summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`;
- }
- if (closedDiscussionsSummary.length > 0) {
- summaryContent += "\n### Closed Older Discussions\n";
- for (const closed of closedDiscussionsSummary) {
- summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`;
- }
- }
- await core.summary.addRaw(summaryContent).write();
- }
- core.info(`Successfully created ${createdDiscussions.length} discussion(s)`);
- }
- (async () => { await main(); })();
+ global.core = core;
+ global.github = github;
+ global.context = context;
+ global.exec = exec;
+ global.io = io;
+ const { main } = require('/tmp/gh-aw/actions/create_discussion.cjs');
+ await main();
update_cache_memory:
needs:
diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml
index 86e58c475a..4a416cec57 100644
--- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml
+++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml
@@ -6341,6 +6341,15 @@ jobs:
create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }}
create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }}
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ sparse-checkout: |
+ actions
+ - name: Setup Scripts
+ uses: actions/setup
+ with:
+ destination: /tmp/gh-aw/actions
- name: Download agent output artifact
continue-on-error: true
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
@@ -6352,887 +6361,6 @@ jobs:
mkdir -p /tmp/gh-aw/safeoutputs/
find "/tmp/gh-aw/safeoutputs/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Setup JavaScript files
- id: setup_scripts
- shell: bash
- run: |
- mkdir -p /tmp/gh-aw/scripts
- cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3'
- // @ts-check
- ///
-
- const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs');
-
- /**
- * Maximum number of older discussions to close
- */
- const MAX_CLOSE_COUNT = 10;
-
- /**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
- const GRAPHQL_DELAY_MS = 500;
-
- /**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- /**
- * Search for open discussions with a matching title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
- * @param {string[]} labels - Labels to match (empty array to skip label matching)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {number} excludeNumber - Discussion number to exclude (the newly created one)
- * @returns {Promise>} Matching discussions
- */
- async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) {
- // Build GraphQL search query
- // Search for open discussions, optionally with title prefix or labels
- let searchQuery = `repo:${owner}/${repo} is:open`;
-
- if (titlePrefix) {
- // Escape quotes in title prefix to prevent query injection
- const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
- searchQuery += ` in:title "${escapedPrefix}"`;
- }
-
- // Add label filters to the search query
- // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels.
- // We add each label as a separate filter and also validate client-side for extra safety.
- if (labels && labels.length > 0) {
- for (const label of labels) {
- // Escape quotes in label names to prevent query injection
- const escapedLabel = label.replace(/"/g, '\\"');
- searchQuery += ` label:"${escapedLabel}"`;
- }
- }
-
- const result = await github.graphql(
- `
- query($searchTerms: String!, $first: Int!) {
- search(query: $searchTerms, type: DISCUSSION, first: $first) {
- nodes {
- ... on Discussion {
- id
- number
- title
- url
- category {
- id
- }
- labels(first: 100) {
- nodes {
- name
- }
- }
- closed
- }
- }
- }
- }`,
- { searchTerms: searchQuery, first: 50 }
- );
-
- if (!result || !result.search || !result.search.nodes) {
- return [];
- }
-
- // Filter results:
- // 1. Must not be the excluded discussion (newly created one)
- // 2. Must not be already closed
- // 3. If titlePrefix is specified, must have title starting with the prefix
- // 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
- // 5. If categoryId is specified, must match
- return result.search.nodes
- .filter(
- /** @param {any} d */ d => {
- if (!d || d.number === excludeNumber || d.closed) {
- return false;
- }
-
- // Check title prefix if specified
- if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) {
- return false;
- }
-
- // Check labels if specified - requires ALL labels to match (AND logic)
- // This is intentional: we only want to close discussions that have ALL the specified labels
- if (labels && labels.length > 0) {
- const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || [];
- const hasAllLabels = labels.every(label => discussionLabels.includes(label));
- if (!hasAllLabels) {
- return false;
- }
- }
-
- // Check category if specified
- if (categoryId && (!d.category || d.category.id !== categoryId)) {
- return false;
- }
-
- return true;
- }
- )
- .map(
- /** @param {any} d */ d => ({
- id: d.id,
- number: d.number,
- title: d.title,
- url: d.url,
- })
- );
- }
-
- /**
- * Add comment to a GitHub Discussion using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @param {string} message - Comment body
- * @returns {Promise<{id: string, url: string}>} Comment details
- */
- async function addDiscussionComment(github, discussionId, message) {
- const result = await github.graphql(
- `
- mutation($dId: ID!, $body: String!) {
- addDiscussionComment(input: { discussionId: $dId, body: $body }) {
- comment {
- id
- url
- }
- }
- }`,
- { dId: discussionId, body: message }
- );
-
- return result.addDiscussionComment.comment;
- }
-
- /**
- * Close a GitHub Discussion as OUTDATED using GraphQL
- * @param {any} github - GitHub GraphQL instance
- * @param {string} discussionId - Discussion node ID
- * @returns {Promise<{id: string, url: string}>} Discussion details
- */
- async function closeDiscussionAsOutdated(github, discussionId) {
- const result = await github.graphql(
- `
- mutation($dId: ID!) {
- closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
- discussion {
- id
- url
- }
- }
- }`,
- { dId: discussionId }
- );
-
- return result.closeDiscussion.discussion;
- }
-
- /**
- * Close older discussions that match the title prefix and/or labels
- * @param {any} github - GitHub GraphQL instance
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} titlePrefix - Title prefix to match (empty string to skip)
- * @param {string[]} labels - Labels to match (empty array to skip)
- * @param {string|undefined} categoryId - Optional category ID to filter by
- * @param {{number: number, url: string}} newDiscussion - The newly created discussion
- * @param {string} workflowName - Name of the workflow
- * @param {string} runUrl - URL of the workflow run
- * @returns {Promise