diff --git a/.github/workflows/ai-triage-campaign.lock.yml b/.github/workflows/ai-triage-campaign.lock.yml index 4d0a98dab9..8d8960ad64 100644 --- a/.github/workflows/ai-triage-campaign.lock.yml +++ b/.github/workflows/ai-triage-campaign.lock.yml @@ -7042,58 +7042,18 @@ jobs: } return p.title === parsedProjectName; }); - if (existingProject) { - try { - await githubClient.graphql( - `mutation($projectId: ID!, $repositoryId: ID!) { - linkProjectV2ToRepository(input: { - projectId: $projectId, - repositoryId: $repositoryId - }) { - repository { - id - } - } - }`, - { projectId: existingProject.id, repositoryId } - ); - } catch (linkError) { - if (!linkError.message || !linkError.message.includes("already linked")) { - core.warning(`Could not link project: ${linkError.message}`); - } - } - } - if (existingProject) { - projectId = existingProject.id; - projectNumber = existingProject.number; - } else { - if (ownerType === "User") { - const projectDisplay = parsedProjectNumber ? `project #${parsedProjectNumber}` : `project "${parsedProjectName}"`; - core.error(`Cannot find ${projectDisplay}. Create it manually at https://github.com/users/${owner}/projects/new.`); - throw new Error(`Cannot find ${projectDisplay} on user account.`); - } - const createResult = await githubClient.graphql( - `mutation($ownerId: ID!, $title: String!) { - createProjectV2(input: { - ownerId: $ownerId, - title: $title - }) { - projectV2 { - id - title - url - number - } - } - }`, - { - ownerId: ownerId, - title: output.project, - } - ); - const newProject = createResult.createProjectV2.projectV2; - projectId = newProject.id; - projectNumber = newProject.number; + if (!existingProject) { + const projectDisplay = parsedProjectNumber ? `project #${parsedProjectNumber}` : `project "${parsedProjectName}"`; + const createHint = + ownerType === "User" + ? `Create it manually at https://github.com/users/${owner}/projects/new or use create-project safe-output.` + : `Use create-project safe-output to create it first.`; + core.error(`Cannot find ${projectDisplay}. ${createHint}`); + throw new Error(`Project not found: ${projectDisplay}`); + } + projectId = existingProject.id; + projectNumber = existingProject.number; + try { await githubClient.graphql( `mutation($projectId: ID!, $repositoryId: ID!) { linkProjectV2ToRepository(input: { @@ -7105,13 +7065,12 @@ jobs: } } }`, - { projectId, repositoryId } + { projectId: existingProject.id, repositoryId } ); - core.info(`✓ Created project: ${newProject.title}`); - core.setOutput("project-id", projectId); - core.setOutput("project-number", projectNumber); - core.setOutput("project-url", newProject.url); - core.setOutput("campaign-id", campaignId); + } catch (linkError) { + if (!linkError.message || !linkError.message.includes("already linked")) { + core.warning(`Could not link project: ${linkError.message}`); + } } const hasContentNumber = output.content_number !== undefined && output.content_number !== null; const hasIssue = output.issue !== undefined && output.issue !== null; diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 08ec10068f..aec94c53a4 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -18,14 +18,14 @@ # gh aw compile # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # -# Create an empty pull request for agent to push changes to +# Test create-project and update-project safe outputs # # Original Frontmatter: # ```yaml # on: # workflow_dispatch: # name: Dev -# description: Create an empty pull request for agent to push changes to +# description: Test create-project and update-project safe outputs # timeout-minutes: 5 # strict: false # engine: copilot @@ -39,8 +39,14 @@ # imports: # - shared/gh.md # safe-outputs: -# create-pull-request: -# allow-empty: true +# create-project: +# max: 1 +# github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} +# update-project: +# max: 5 +# github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} +# create-issue: +# staged: true # steps: # - name: Download issues data # run: | @@ -59,17 +65,24 @@ # activation["activation"] # agent["agent"] # conclusion["conclusion"] -# create_pull_request["create_pull_request"] +# create_issue["create_issue"] +# create_project["create_project"] # detection["detection"] +# update_project["update_project"] # activation --> agent # activation --> conclusion -# activation --> create_pull_request # agent --> conclusion -# agent --> create_pull_request +# agent --> create_issue +# agent --> create_project # agent --> detection -# create_pull_request --> conclusion +# agent --> update_project +# create_issue --> conclusion +# create_project --> conclusion # detection --> conclusion -# detection --> create_pull_request +# detection --> create_issue +# detection --> create_project +# detection --> update_project +# update_project --> conclusion # ``` # # Original Prompt: @@ -91,11 +104,13 @@ # # # -# Create an empty pull request that prepares a branch for future changes. -# The pull request should have: -# - Title: "Feature: Prepare branch for agent updates" -# - Body: "This is an empty pull request created to prepare a feature branch that an agent can push changes to later." -# - Branch name: "feature/agent-updates" +# Create a new GitHub Projects v2 board and then add items to it. +# +# 1. Use the `create-project` safe output **with a `project` field** to create a project board named exactly `Dev Project Test` linked to this repository. The safe-output item MUST set `project` to a string, for example: `project: "Dev Project Test"`. +# 2. Confirm the project exists (idempotent: re-using the same name should return the existing board). +# 3. Use the `update-project` safe output **with a `project` field** set to `Dev Project Test` to add at least one issue from this repository to the project. +# 4. When calling `update-project`, also include `content_number` for the issue number you are adding, and set simple fields on the project item such as Status (e.g., "Todo") and Priority (e.g., "Medium"). +# 5. If any step fails, explain what happened and how to fix it in a short summary. # ``` # # Pinned GitHub Actions: @@ -340,32 +355,39 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /tmp/gh-aw/safeoutputs/config.json << 'EOF' - {"create_pull_request":{"allow_empty":true},"missing_tool":{"max":0},"noop":{"max":1}} + {"create_issue":{"max":1},"create_project":{"max":1},"missing_tool":{"max":0},"noop":{"max":1},"update_project":{"max":5}} EOF cat > /tmp/gh-aw/safeoutputs/tools.json << 'EOF' [ { - "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created.", + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.", "inputSchema": { "additionalProperties": false, "properties": { "body": { - "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", - "type": "string" - }, - "branch": { - "description": "Source branch name containing the changes. If omitted, uses the current working branch.", + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", "type": "string" }, "labels": { - "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", "items": { "type": "string" }, "type": "array" }, + "parent": { + "description": "Parent issue number for creating sub-issues. Can be a real issue number (e.g., 42) or a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "type": "string" + }, "title": { - "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", "type": "string" } }, @@ -375,7 +397,7 @@ jobs: ], "type": "object" }, - "name": "create_pull_request" + "name": "create_issue" }, { "description": "Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", @@ -424,7 +446,7 @@ jobs: EOF cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' { - "create_pull_request": { + "create_issue": { "defaultMax": 1, "fields": { "body": { @@ -433,18 +455,22 @@ jobs: "sanitize": true, "maxLength": 65000 }, - "branch": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, "labels": { "type": "array", "itemType": "string", "itemSanitize": true, "itemMaxLength": 128 }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, "title": { "required": true, "type": "string", @@ -1451,7 +1477,7 @@ jobs: const { getCurrentBranch } = require("./get_current_branch.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { generateGitPatch } = require("./generate_git_patch.cjs"); - function createHandlers(server, appendSafeOutput, config = {}) { + function createHandlers(server, appendSafeOutput) { const defaultHandler = type => args => { const entry = { ...(args || {}), type }; let largeContent = null; @@ -1573,23 +1599,6 @@ jobs: } entry.branch = detectedBranch; } - const allowEmpty = config.create_pull_request?.allow_empty === true; - if (allowEmpty) { - server.debug(`allow-empty is enabled for create_pull_request - skipping patch generation`); - appendSafeOutput(entry); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - result: "success", - message: "Pull request prepared (allow-empty mode - no patch generated)", - branch: entry.branch, - }), - }, - ], - }; - } server.debug(`Generating patch for create_pull_request with branch: ${entry.branch}`); const patchResult = generateGitPatch(entry.branch); if (!patchResult.success) { @@ -1673,7 +1682,7 @@ jobs: const server = createServer(SERVER_INFO, { logDir: MCP_LOG_DIR }); const { config: safeOutputsConfig, outputFile, tools: ALL_TOOLS } = bootstrapSafeOutputsServer(server); const appendSafeOutput = createAppendFunction(outputFile); - const handlers = createHandlers(server, appendSafeOutput, safeOutputsConfig); + const handlers = createHandlers(server, appendSafeOutput); const { defaultHandler } = handlers; const toolsWithHandlers = attachHandlers(ALL_TOOLS, handlers); server.debug(` output file: ${outputFile}`); @@ -3216,7 +3225,7 @@ jobs: sha: context.sha, actor: context.actor, event_name: context.eventName, - staged: false, + staged: true, network_mode: "defaults", allowed_domains: ["api.github.com"], firewall_enabled: true, @@ -3299,11 +3308,13 @@ jobs: - Create an empty pull request that prepares a branch for future changes. - The pull request should have: - - Title: "Feature: Prepare branch for agent updates" - - Body: "This is an empty pull request created to prepare a feature branch that an agent can push changes to later." - - Branch name: "feature/agent-updates" + Create a new GitHub Projects v2 board and then add items to it. + + 1. Use the `create-project` safe output **with a `project` field** to create a project board named exactly `Dev Project Test` linked to this repository. The safe-output item MUST set `project` to a string, for example: `project: "Dev Project Test"`. + 2. Confirm the project exists (idempotent: re-using the same name should return the existing board). + 3. Use the `update-project` safe output **with a `project` field** set to `Dev Project Test` to add at least one issue from this repository to the project. + 4. When calling `update-project`, also include `content_number` for the issue number you are adding, and set simple fields on the project item such as Status (e.g., "Todo") and Priority (e.g., "Medium"). + 5. If any step fails, explain what happened and how to fix it in a short summary. PROMPT_EOF - name: Append XPIA security instructions to prompt @@ -3367,7 +3378,7 @@ jobs: To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - **Available tools**: create_pull_request, missing_tool, noop + **Available tools**: create_issue, create_project, missing_tool, noop, update_project **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. @@ -3422,6 +3433,7 @@ jobs: GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_STAGED: true GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} @@ -3737,35 +3749,7 @@ jobs: return s.replace(//g, "").replace(//g, ""); } function convertXmlTags(s) { - const allowedTags = [ - "b", - "blockquote", - "br", - "code", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "i", - "li", - "ol", - "p", - "pre", - "strong", - "sub", - "sup", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", - ]; + const allowedTags = ["details", "summary", "code", "em", "b", "p", "strong", "i", "u", "br", "ul", "ol", "li", "blockquote"]; s = s.replace(//g, (match, content) => { const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)"); return `(![CDATA[${convertedContent}]])`; @@ -4499,22 +4483,7 @@ jobs: const patchPath = "/tmp/gh-aw/aw.patch"; const hasPatch = fs.existsSync(patchPath); core.info(`Patch file ${hasPatch ? "exists" : "does not exist"} at: ${patchPath}`); - let allowEmptyPR = false; - if (safeOutputsConfig) { - if ( - safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true || - safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true - ) { - allowEmptyPR = true; - core.info(`allow-empty is enabled for create-pull-request`); - } - } - if (allowEmptyPR && !hasPatch && outputTypes.includes("create_pull_request")) { - core.info(`allow-empty is enabled and no patch exists - will create empty PR`); - core.setOutput("has_patch", "true"); - } else { - core.setOutput("has_patch", hasPatch ? "true" : "false"); - } + core.setOutput("has_patch", hasPatch ? "true" : "false"); } await main(); - name: Upload sanitized agent output @@ -6421,20 +6390,15 @@ jobs: if (typeof module === "undefined" || require.main === module) { main(); } - - name: Upload git patch - if: always() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: aw.patch - path: /tmp/gh-aw/aw.patch - if-no-files-found: ignore conclusion: needs: - activation - agent - - create_pull_request + - create_issue + - create_project - detection + - update_project if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -6683,8 +6647,8 @@ jobs: GH_AW_WORKFLOW_NAME: "Dev" GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} - GH_AW_SAFE_OUTPUT_JOBS: "{\"create_pull_request\":\"pull_request_url\"}" - GH_AW_OUTPUT_CREATE_PULL_REQUEST_PULL_REQUEST_URL: ${{ needs.create_pull_request.outputs.pull_request_url }} + GH_AW_SAFE_OUTPUT_JOBS: "{\"create_issue\":\"issue_url\"}" + GH_AW_OUTPUT_CREATE_ISSUE_ISSUE_URL: ${{ needs.create_issue.outputs.issue_url }} with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -6939,50 +6903,23 @@ jobs: core.setFailed(error instanceof Error ? error.message : String(error)); }); - create_pull_request: + create_issue: needs: - - activation - agent - detection if: > - (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))) && + (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))) && (needs.detection.outputs.success == 'true') runs-on: ubuntu-slim permissions: - contents: write + contents: read issues: write - pull-requests: write timeout-minutes: 10 outputs: - branch_name: ${{ steps.create_pull_request.outputs.branch_name }} - fallback_used: ${{ steps.create_pull_request.outputs.fallback_used }} - issue_number: ${{ steps.create_pull_request.outputs.issue_number }} - issue_url: ${{ steps.create_pull_request.outputs.issue_url }} - pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + issue_number: ${{ steps.create_issue.outputs.issue_number }} + issue_url: ${{ steps.create_issue.outputs.issue_url }} + temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} steps: - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: aw.patch - path: /tmp/gh-aw/ - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - persist-credentials: false - fetch-depth: 0 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - name: Download agent output artifact continue-on-error: true uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 @@ -6994,129 +6931,138 @@ 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: Create Pull Request - id: create_pull_request + - name: Create Output Issue + id: create_issue uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_ID: "agent" - GH_AW_BASE_BRANCH: ${{ github.ref_name }} - GH_AW_PR_DRAFT: "true" - GH_AW_PR_IF_NO_CHANGES: "warn" - GH_AW_PR_ALLOW_EMPTY: "true" - GH_AW_MAX_PATCH_SIZE: 1024 GH_AW_WORKFLOW_NAME: "Dev" GH_AW_ENGINE_ID: "copilot" + GH_AW_SAFE_OUTPUTS_STAGED: "true" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + 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(); + } const fs = require("fs"); const crypto = require("crypto"); - 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); - } - 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"); - } - 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 (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; } - core.info(`Updating activation comment ${commentId}`); - 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.`); - } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); + let outputContent; try { - if (isDiscussionComment) { - 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; - 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 { - 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; - 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}`); - } + 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 }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + 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 }; + } + 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 }; + } + 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.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + core.setFailed(error instanceof Error ? error : String(error)); } } + function generateXMLMarker(workflowName, runUrl) { + 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 || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + 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/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } function getTrackerID(format) { const trackerID = process.env.GH_AW_TRACKER_ID || ""; if (trackerID) { @@ -7125,524 +7071,682 @@ jobs: } return ""; } - 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)`); - } - } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); } - function removeDuplicateTitleFromDescription(title, description) { - 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; - } - 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(); + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); } - return trimmedDescription; + return false; } - 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
`; + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); } - 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; + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; } + return `${resolved.repo}#${resolved.number}`; } - } - 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; - } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); } - 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; + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + 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) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); } - 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; } + 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(); } - 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"); + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + 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.`, + }; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; + 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 pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); - return; + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + 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)); } - 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`; - } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + 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(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + 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)`); } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + } + } + 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; } - 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 createIssueItems = result.items.filter(item => item.type === "create_issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + 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(", ")}`); } - 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 + 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) + .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 latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${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]); + 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; } - 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)}` - ); + 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.setFailed("Failed to apply patch"); - return; } + 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); + 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 { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + 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)}` + ); + } } - } 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"); + } 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); - 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; } + } 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; } - } 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; + } + 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(); + })(); + + create_project: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_project')) + runs-on: ubuntu-slim + permissions: + contents: read + repository-projects: write + timeout-minutes: 10 + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + 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 Project + id: create_project + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_STAGED: "true" + with: + github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + 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})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + 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 }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + 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 }; + } + 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 }; + } + function generateCampaignId(projectName) { + const slug = projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .substring(0, 30); + const timestamp = Date.now().toString(36).substring(0, 8); + return `${slug}-${timestamp}`; + } + async function createProject(output) { + const { owner, repo } = context.repo; + if (!output.project || typeof output.project !== "string") { + throw new Error( + `Invalid project name: expected string, got ${typeof output.project}. The "project" field is required and must be a project title.` + ); + } + const campaignId = output.campaign_id || generateCampaignId(output.project); + let githubClient = github; + if (process.env.PROJECT_GITHUB_TOKEN) { + const { Octokit } = require("@octokit/rest"); + const octokit = new Octokit({ + auth: process.env.PROJECT_GITHUB_TOKEN, + baseUrl: process.env.GITHUB_API_URL || "https://api.github.com", + }); + githubClient = { + graphql: octokit.graphql.bind(octokit), + rest: octokit.rest, + }; + } + try { + const repoResult = await githubClient.graphql( + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + id + owner { + id + __typename } - } 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}`); + }`, + { owner, repo } + ); + const repositoryId = repoResult.repository.id; + const ownerId = repoResult.repository.owner.id; + const ownerType = repoResult.repository.owner.__typename; + if (ownerType === "User") { + core.error(`Cannot create projects on user accounts. Create the project manually at https://github.com/users/${owner}/projects/new.`); + throw new Error(`Cannot create project "${output.project}" on user account.`); + } + const ownerQuery = `query($login: String!) { + organization(login: $login) { + projectsV2(first: 100) { + nodes { + id + title + number + } } - 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; + }`; + const ownerProjectsResult = await githubClient.graphql(ownerQuery, { login: owner }); + const ownerProjects = ownerProjectsResult.organization.projectsV2.nodes; + const existingProject = ownerProjects.find(p => p.title === output.project); + if (existingProject) { + core.info(`✓ Project already exists: ${existingProject.title} (#${existingProject.number})`); + core.setOutput("project-id", existingProject.id); + core.setOutput("project-number", existingProject.number); + core.setOutput("campaign-id", campaignId); + try { + await githubClient.graphql( + `mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { + projectId: $projectId, + repositoryId: $repositoryId + }) { + repository { + id + } + } + }`, + { projectId: existingProject.id, repositoryId } + ); + } catch (linkError) { + if (!linkError.message || !linkError.message.includes("already linked")) { + core.warning(`Could not link project: ${linkError.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)}`); + const createResult = await githubClient.graphql( + `mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: { + ownerId: $ownerId, + title: $title + }) { + projectV2 { + id + title + url + number + } + } + }`, + { + ownerId: ownerId, + title: output.project, + } + ); + const newProject = createResult.createProjectV2.projectV2; + await githubClient.graphql( + `mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { + projectId: $projectId, + repositoryId: $repositoryId + }) { + repository { + id + } + } + }`, + { projectId: newProject.id, repositoryId } + ); + core.info(`✓ Created project: ${newProject.title}`); + core.setOutput("project-id", newProject.id); + core.setOutput("project-number", newProject.number); + core.setOutput("project-url", newProject.url); + core.setOutput("campaign-id", campaignId); + } catch (error) { + if (error.message && error.message.includes("does not have permission to create projects")) { + const usingCustomToken = !!process.env.PROJECT_GITHUB_TOKEN; + core.error( + `Failed to create project: ${error.message}\n\n` + + `Troubleshooting:\n` + + ` â€ĸ Create the project manually at https://github.com/orgs/${owner}/projects/new.\n` + + ` â€ĸ Or supply a PAT with project scope via PROJECT_GITHUB_TOKEN.\n` + + ` â€ĸ Ensure the workflow grants projects: write.\n\n` + + `${usingCustomToken ? "PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN without project create rights."}` + ); + } else { + core.error(`Failed to create project: ${error.message}`); } - 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}`; + throw error; + } + } + async function main() { + const result = loadAgentOutput(); + if (!result.success) { + return; + } + const createProjectItems = result.items.filter(item => item.type === "create_project"); + if (createProjectItems.length === 0) { + return; + } + for (let i = 0; i < createProjectItems.length; i++) { + const output = createProjectItems[i]; 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; + await createProject(output); + } catch (error) { + core.error(`Failed to process item ${i + 1}: ${error.message}`); } } } - await main(); + if (typeof module === "undefined" || require.main === module) { + main(); + } detection: needs: agent @@ -7682,7 +7786,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: WORKFLOW_NAME: "Dev" - WORKFLOW_DESCRIPTION: "Create an empty pull request for agent to push changes to" + WORKFLOW_DESCRIPTION: "Test create-project and update-project safe outputs" with: script: | const fs = require('fs'); @@ -7893,3 +7997,560 @@ jobs: path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore + update_project: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project')) + runs-on: ubuntu-slim + permissions: + contents: read + repository-projects: write + timeout-minutes: 10 + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + 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: Update Project + id: update_project + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_STAGED: "true" + with: + github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + 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})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + 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 }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + 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 }; + } + 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 }; + } + function parseProjectInput(projectInput) { + if (!projectInput || typeof projectInput !== "string") { + throw new Error( + `Invalid project input: expected string, got ${typeof projectInput}. The "project" field is required and must be a GitHub project URL, number, or name.` + ); + } + const urlMatch = projectInput.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); + if (urlMatch) { + return { + projectNumber: urlMatch[1], + projectName: null, + }; + } + return { + projectNumber: /^\d+$/.test(projectInput) ? projectInput : null, + projectName: /^\d+$/.test(projectInput) ? null : projectInput, + }; + } + function generateCampaignId(projectName) { + const slug = projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .substring(0, 30); + const timestamp = Date.now().toString(36).substring(0, 8); + return `${slug}-${timestamp}`; + } + async function updateProject(output) { + const { owner, repo } = context.repo; + const { projectNumber: parsedProjectNumber, projectName: parsedProjectName } = parseProjectInput(output.project); + const displayName = parsedProjectName || parsedProjectNumber || output.project; + const campaignId = output.campaign_id || generateCampaignId(displayName); + let githubClient = github; + if (process.env.PROJECT_GITHUB_TOKEN) { + const { Octokit } = require("@octokit/rest"); + const octokit = new Octokit({ + auth: process.env.PROJECT_GITHUB_TOKEN, + baseUrl: process.env.GITHUB_API_URL || "https://api.github.com", + }); + githubClient = { + graphql: octokit.graphql.bind(octokit), + rest: octokit.rest, + }; + } + try { + const repoResult = await githubClient.graphql( + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + id + owner { + id + __typename + } + } + }`, + { owner, repo } + ); + const repositoryId = repoResult.repository.id; + const ownerId = repoResult.repository.owner.id; + const ownerType = repoResult.repository.owner.__typename; + let projectId; + let projectNumber; + let existingProject = null; + const ownerQuery = + ownerType === "User" + ? `query($login: String!) { + user(login: $login) { + projectsV2(first: 100) { + nodes { + id + title + number + } + } + } + }` + : `query($login: String!) { + organization(login: $login) { + projectsV2(first: 100) { + nodes { + id + title + number + } + } + } + }`; + const ownerProjectsResult = await githubClient.graphql(ownerQuery, { login: owner }); + const ownerProjects = + ownerType === "User" ? ownerProjectsResult.user.projectsV2.nodes : ownerProjectsResult.organization.projectsV2.nodes; + existingProject = ownerProjects.find(p => { + if (parsedProjectNumber) { + return p.number.toString() === parsedProjectNumber; + } + return p.title === parsedProjectName; + }); + if (!existingProject) { + const projectDisplay = parsedProjectNumber ? `project #${parsedProjectNumber}` : `project "${parsedProjectName}"`; + const createHint = + ownerType === "User" + ? `Create it manually at https://github.com/users/${owner}/projects/new or use create-project safe-output.` + : `Use create-project safe-output to create it first.`; + core.error(`Cannot find ${projectDisplay}. ${createHint}`); + throw new Error(`Project not found: ${projectDisplay}`); + } + projectId = existingProject.id; + projectNumber = existingProject.number; + try { + await githubClient.graphql( + `mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { + projectId: $projectId, + repositoryId: $repositoryId + }) { + repository { + id + } + } + }`, + { projectId: existingProject.id, repositoryId } + ); + } catch (linkError) { + if (!linkError.message || !linkError.message.includes("already linked")) { + core.warning(`Could not link project: ${linkError.message}`); + } + } + const hasContentNumber = output.content_number !== undefined && output.content_number !== null; + const hasIssue = output.issue !== undefined && output.issue !== null; + const hasPullRequest = output.pull_request !== undefined && output.pull_request !== null; + const values = []; + if (hasContentNumber) values.push({ key: "content_number", value: output.content_number }); + if (hasIssue) values.push({ key: "issue", value: output.issue }); + if (hasPullRequest) values.push({ key: "pull_request", value: output.pull_request }); + if (values.length > 1) { + const uniqueValues = [...new Set(values.map(v => String(v.value)))]; + const list = values.map(v => `${v.key}=${v.value}`).join(", "); + const 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.`); + } + if (hasIssue) { + core.warning('Field "issue" deprecated; use "content_number" instead.'); + } + if (hasPullRequest) { + core.warning('Field "pull_request" deprecated; use "content_number" instead.'); + } + let contentNumber = null; + if (hasContentNumber || hasIssue || hasPullRequest) { + const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request; + const sanitizedContentNumber = + rawContentNumber === undefined || rawContentNumber === null + ? "" + : typeof rawContentNumber === "number" + ? rawContentNumber.toString() + : String(rawContentNumber).trim(); + if (!sanitizedContentNumber) { + core.warning("Content number field provided but empty; skipping project item update."); + } else if (!/^\d+$/.test(sanitizedContentNumber)) { + throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`); + } else { + contentNumber = Number.parseInt(sanitizedContentNumber, 10); + } + } + if (contentNumber !== null) { + const contentType = + output.content_type === "pull_request" + ? "PullRequest" + : output.content_type === "issue" + ? "Issue" + : output.issue + ? "Issue" + : "PullRequest"; + const contentQuery = + contentType === "Issue" + ? `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + id + } + } + }` + : `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + } + } + }`; + const contentResult = await githubClient.graphql(contentQuery, { + owner, + repo, + number: contentNumber, + }); + const contentId = contentType === "Issue" ? contentResult.repository.issue.id : contentResult.repository.pullRequest.id; + async function findExistingProjectItem(projectId, contentId) { + let hasNextPage = true; + let endCursor = null; + while (hasNextPage) { + const result = await githubClient.graphql( + `query($projectId: ID!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $after) { + nodes { + id + content { + ... on Issue { + id + } + ... on PullRequest { + id + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + }`, + { projectId, after: endCursor } + ); + const items = result.node.items.nodes; + const found = items.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; + } + const existingItem = await findExistingProjectItem(projectId, contentId); + let itemId; + if (existingItem) { + itemId = existingItem.id; + core.info("✓ Item already on board"); + } else { + const addResult = await githubClient.graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { + projectId: $projectId, + contentId: $contentId + }) { + item { + id + } + } + }`, + { projectId, contentId } + ); + itemId = addResult.addProjectV2ItemById.item.id; + try { + await githubClient.rest.issues.addLabels({ + owner, + repo, + issue_number: contentNumber, + labels: [`campaign:${campaignId}`], + }); + } catch (labelError) { + core.warning(`Failed to add campaign label: ${labelError.message}`); + } + } + if (output.fields && Object.keys(output.fields).length > 0) { + const fieldsResult = await githubClient.graphql( + `query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + }`, + { projectId } + ); + const projectFields = fieldsResult.node.fields.nodes; + for (const [fieldName, fieldValue] of Object.entries(output.fields)) { + const normalizedFieldName = fieldName + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + if (!field) { + const isTextField = + fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); + if (isTextField) { + try { + const createFieldResult = await githubClient.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + }`, + { + projectId, + name: normalizedFieldName, + dataType: "TEXT", + } + ); + field = createFieldResult.createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; + } + } else { + try { + const createFieldResult = await githubClient.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + ... on ProjectV2Field { + id + name + } + } + } + }`, + { + projectId, + name: normalizedFieldName, + dataType: "SINGLE_SELECT", + options: [{ name: String(fieldValue), description: "", color: "GRAY" }], + } + ); + field = createFieldResult.createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; + } + } + } + let valueToSet; + 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: "" })), + { name: String(fieldValue), description: "" }, + ]; + const createOptionResult = await githubClient.graphql( + `mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + updateProjectV2Field(input: { + fieldId: $fieldId, + name: $fieldName, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + options { + id + name + } + } + } + } + }`, + { + fieldId: field.id, + fieldName: field.name, + options: allOptions, + } + ); + const updatedField = createOptionResult.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) { + valueToSet = { singleSelectOptionId: option.id }; + } else { + core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); + continue; + } + } else { + valueToSet = { text: String(fieldValue) }; + } + await githubClient.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: $value + }) { + projectV2Item { + id + } + } + }`, + { + 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.PROJECT_GITHUB_TOKEN; + core.error( + `Failed to manage project: ${error.message}\n\n` + + `Troubleshooting:\n` + + ` â€ĸ Create the project manually at https://github.com/orgs/${owner}/projects/new.\n` + + ` â€ĸ Or supply a PAT with project scope via PROJECT_GITHUB_TOKEN.\n` + + ` â€ĸ Ensure the workflow grants projects: write.\n\n` + + `${usingCustomToken ? "PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN without project create rights."}` + ); + } 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 => item.type === "update_project"); + if (updateProjectItems.length === 0) { + return; + } + 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}: ${error.message}`); + } + } + } + if (typeof module === "undefined" || require.main === module) { + main(); + } + diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 52cdfe4f8f..fa465e45a7 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -2,7 +2,7 @@ on: workflow_dispatch: name: Dev -description: Create an empty pull request for agent to push changes to +description: Test create-project and update-project safe outputs timeout-minutes: 5 strict: false engine: copilot @@ -16,8 +16,14 @@ tools: imports: - shared/gh.md safe-outputs: - create-pull-request: - allow-empty: true + create-project: + max: 1 + github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} + update-project: + max: 5 + github-token: ${{ secrets.PROJECT_PAT || secrets.GITHUB_TOKEN }} + create-issue: + staged: true steps: - name: Download issues data run: | @@ -26,8 +32,10 @@ steps: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} --- -Create an empty pull request that prepares a branch for future changes. -The pull request should have: -- Title: "Feature: Prepare branch for agent updates" -- Body: "This is an empty pull request created to prepare a feature branch that an agent can push changes to later." -- Branch name: "feature/agent-updates" +Create a new GitHub Projects v2 board and then add items to it. + +1. Use the `create-project` safe output **with a `project` field** to create a project board named exactly `Dev Project Test` linked to this repository. The safe-output item MUST set `project` to a string, for example: `project: "Dev Project Test"`. +2. Confirm the project exists (idempotent: re-using the same name should return the existing board). +3. Use the `update-project` safe output **with a `project` field** set to `Dev Project Test` to add at least one issue from this repository to the project. +4. When calling `update-project`, also include `content_number` for the issue number you are adding, and set simple fields on the project item such as Status (e.g., "Todo") and Priority (e.g., "Medium"). +5. If any step fails, explain what happened and how to fix it in a short summary. diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index c38fb592be..78c7332ca2 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -32,7 +32,8 @@ This declares that the workflow should create at most one new issue. | [**Update Issue**](#issue-updates-update-issue) | `update-issue:` | Update issue status, title, or body | 1 | ✅ | | [**Update PR**](#pull-request-updates-update-pull-request) | `update-pull-request:` | Update PR title or body | 1 | ✅ | | [**Link Sub-Issue**](#link-sub-issue-link-sub-issue) | `link-sub-issue:` | Link issues as sub-issues | 1 | ✅ | -| [**Update Project**](#project-board-updates-update-project) | `update-project:` | Manage GitHub Projects boards and campaign labels | 10 | ❌ | +| [**Create Project**](#project-creation-create-project) | `create-project:` | Create GitHub Projects v2 boards | 10 | ❌ | +| [**Update Project**](#project-board-updates-update-project) | `update-project:` | Add items to project boards and update fields | 10 | ❌ | | [**Add Labels**](#add-labels-add-labels) | `add-labels:` | Add labels to issues or PRs | 3 | ✅ | | [**Add Reviewer**](#add-reviewer-add-reviewer) | `add-reviewer:` | Add reviewers to pull requests | 3 | ✅ | | [**Assign Milestone**](#assign-milestone-assign-milestone) | `assign-milestone:` | Assign issues to milestones | 1 | ✅ | @@ -331,9 +332,26 @@ safe-outputs: Agent output includes `parent_issue_number` and `sub_issue_number`. Validation ensures both issues exist and meet label/prefix requirements before linking. +### Project Creation (`create-project:`) + +Creates new GitHub Projects v2 boards. Generated job runs with `projects: write` permissions, creates the project, and links it to the repository. + +```yaml wrap +safe-outputs: + create-project: + max: 5 # max project creations (default: 1) + github-token: ${{ secrets.PROJECTS_PAT }} # token override with projects:write +``` + +Agent output must include a `project` name and can supply a custom `campaign_id`. The job creates the project if it doesn't exist (idempotent - returns existing project if name matches), links it to the repository, and exposes `project-id`, `project-number`, `project-url`, and `campaign-id` outputs. + +:::note[Organization Only] +Project creation only works for organization repositories. User accounts must create projects manually at `https://github.com/users/{username}/projects/new`. +::: + ### Project Board Updates (`update-project:`) -Manages GitHub Projects boards. Generated job runs with `projects: write` permissions, links the board to the repository, and maintains campaign metadata. +Adds items to GitHub Projects v2 boards and updates custom fields. Project must already exist (use `create-project` to create). Generated job runs with `projects: write` permissions. ```yaml wrap safe-outputs: @@ -342,7 +360,7 @@ safe-outputs: github-token: ${{ secrets.PROJECTS_PAT }} # token override with projects:write ``` -Agent output must include a `project` identifier (name, number, or URL) and can supply `content_number`, `content_type`, `fields`, and `campaign_id`. The job adds the issue or PR to the board, updates custom fields, applies `campaign:` labels, and exposes `project-id`, `project-number`, `project-url`, `campaign-id`, and `item-id` outputs. Cross-repository targeting not supported. +Agent output must include a `project` identifier (name, number, or URL) and can supply `content_number`, `content_type`, `fields`, and `campaign_id`. The job finds the existing project, adds the issue or PR to the board, updates custom fields, applies `campaign:` labels, and exposes `project-id`, `project-number`, `campaign-id`, and `item-id` outputs. If the project doesn't exist, the job fails with an error message suggesting to use `create-project` first. Cross-repository targeting not supported. ### Pull Request Creation (`create-pull-request:`) @@ -705,7 +723,7 @@ safe-outputs: ## Campaign Workflows -Combine `create-issue` with `update-project` to launch coordinated initiatives. The project job returns a campaign identifier, applies `campaign:` labels, and keeps boards synchronized. See [Campaign Workflows](/gh-aw/guides/campaigns/). +Combine `create-issue` with `create-project` and `update-project` to launch coordinated initiatives. Use `create-project` to set up the board, then `update-project` to add issues and apply `campaign:` labels for synchronized tracking. See [Campaign Workflows](/gh-aw/guides/campaigns/). ## Custom Messages (`messages:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6d9caa033e..12637ba2eb 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3011,7 +3011,7 @@ "safe-outputs": { "type": "object", "description": "Safe output processing configuration that automatically creates GitHub issues, comments, and pull requests from AI workflow output without requiring write permissions in the main job", - "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-task, create-code-scanning-alert, create-discussion, create-issue, create-pull-request, create-pull-request-review-comment, hide-comment, link-sub-issue, missing-tool, noop, push-to-pull-request-branch, threat-detection, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", + "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-task, create-code-scanning-alert, create-discussion, create-issue, create-project, create-pull-request, create-pull-request-review-comment, hide-comment, link-sub-issue, missing-tool, noop, push-to-pull-request-branch, threat-detection, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", "properties": { "allowed-domains": { "type": "array", @@ -3153,11 +3153,45 @@ } ] }, + "create-project": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for creating GitHub Projects v2 boards. Creates a new project if it doesn't exist. Requires repository-projects: write permission. Safe output items produced by the agent use type=create_project and must include: project (board title). Organization accounts only - user accounts must create projects manually.", + "properties": { + "max": { + "type": "integer", + "description": "Maximum number of project creation operations to perform (default: 1).", + "minimum": 1, + "maximum": 100 + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false, + "examples": [ + { + "max": 5 + }, + { + "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", + "max": 5 + } + ] + }, + { + "type": "null", + "description": "Enable project creation with default configuration (max=1)" + } + ] + }, "update-project": { "oneOf": [ { "type": "object", - "description": "Configuration for managing GitHub Projects v2 boards. Smart tool that auto-detects whether to create a project (if missing), add issue/PR items, or update custom fields on existing items. Requires repository-projects: write permission. Safe output items produced by the agent use type=update_project and may include: project (board name), content_type (issue|pull_request), content_number, and a fields object mapping project field names to values.", + "description": "Configuration for adding items to GitHub Projects v2 boards and updating custom fields. Project must already exist (use create-project to create). Requires repository-projects: write permission. Safe output items produced by the agent use type=update_project and may include: project (board name/number/URL), content_type (issue|pull_request), content_number, and a fields object mapping project field names to values.", "properties": { "max": { "type": "integer", diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 27805f3d74..320387ef40 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -712,6 +712,22 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat safeOutputJobNames = append(safeOutputJobNames, createAgentTaskJob.Name) } + // Build create_project job if safe-outputs.create-project is configured + if data.SafeOutputs.CreateProjects != nil { + createProjectJob, err := c.buildCreateProjectJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build create_project job: %w", err) + } + // Safe-output jobs should depend on agent job (always) AND detection job (if enabled) + if threatDetectionEnabled { + createProjectJob.Needs = append(createProjectJob.Needs, constants.DetectionJobName) + } + if err := c.jobManager.AddJob(createProjectJob); err != nil { + return fmt.Errorf("failed to add create_project job: %w", err) + } + safeOutputJobNames = append(safeOutputJobNames, createProjectJob.Name) + } + // Build update_project job if safe-outputs.update-project is configured if data.SafeOutputs.UpdateProjects != nil { updateProjectJob, err := c.buildUpdateProjectJob(data, jobName) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 0ae45f1f0d..c5c5064029 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -267,7 +267,8 @@ type SafeOutputsConfig struct { UploadAssets *UploadAssetsConfig `yaml:"upload-assets,omitempty"` UpdateRelease *UpdateReleaseConfig `yaml:"update-release,omitempty"` // Update GitHub release descriptions CreateAgentTasks *CreateAgentTaskConfig `yaml:"create-agent-task,omitempty"` // Create GitHub Copilot agent tasks - UpdateProjects *UpdateProjectConfig `yaml:"update-project,omitempty"` // Smart project board management (create/add/update) + CreateProjects *CreateProjectConfig `yaml:"create-project,omitempty"` // Create GitHub Projects v2 boards + UpdateProjects *UpdateProjectConfig `yaml:"update-project,omitempty"` // Add items to project boards and update fields LinkSubIssue *LinkSubIssueConfig `yaml:"link-sub-issue,omitempty"` // Link issues as sub-issues HideComment *HideCommentConfig `yaml:"hide-comment,omitempty"` // Hide comments MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality diff --git a/pkg/workflow/create_project.go b/pkg/workflow/create_project.go new file mode 100644 index 0000000000..3623bf1a50 --- /dev/null +++ b/pkg/workflow/create_project.go @@ -0,0 +1,30 @@ +package workflow + +// CreateProjectConfig holds configuration for creating GitHub Projects v2 +type CreateProjectConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + GitHubToken string `yaml:"github-token,omitempty"` +} + +// parseCreateProjectConfig handles create-project configuration +func (c *Compiler) parseCreateProjectConfig(outputMap map[string]any) *CreateProjectConfig { + if configData, exists := outputMap["create-project"]; exists { + createProjectConfig := &CreateProjectConfig{} + createProjectConfig.Max = 1 // Default max is 1 + + if configMap, ok := configData.(map[string]any); ok { + // Parse base config (max, github-token) + c.parseBaseSafeOutputConfig(configMap, &createProjectConfig.BaseSafeOutputConfig, 1) + + // Parse github-token override if specified + if token, exists := configMap["github-token"]; exists { + if tokenStr, ok := token.(string); ok { + createProjectConfig.GitHubToken = tokenStr + } + } + } + + return createProjectConfig + } + return nil +} diff --git a/pkg/workflow/create_project_job.go b/pkg/workflow/create_project_job.go new file mode 100644 index 0000000000..21b2edae44 --- /dev/null +++ b/pkg/workflow/create_project_job.go @@ -0,0 +1,57 @@ +package workflow + +import ( + "fmt" +) + +// buildCreateProjectJob creates the create_project job +func (c *Compiler) buildCreateProjectJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.CreateProjects == nil { + return nil, fmt.Errorf("safe-outputs.create-project configuration is required") + } + + var steps []string + + // Build custom environment variables specific to create-project + var customEnvVars []string + + // Add common safe output job environment variables (staged/target repo) + // Note: Project operations always work on the current repo, so targetRepoSlug is "" + customEnvVars = append(customEnvVars, buildSafeOutputJobEnvVars( + c.trialMode, + c.trialLogicalRepoSlug, + data.SafeOutputs.Staged, + "", // targetRepoSlug - projects always work on current repo + )...) + + // Get token from config + var token string + if data.SafeOutputs.CreateProjects != nil { + token = data.SafeOutputs.CreateProjects.GitHubToken + } + + // Build the GitHub Script step using the common helper and append to existing steps + scriptSteps := c.buildGitHubScriptStep(data, GitHubScriptStepConfig{ + StepName: "Create Project", + StepID: "create_project", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: getCreateProjectScript(), + Token: token, + }) + steps = append(steps, scriptSteps...) + + jobCondition := BuildSafeOutputType("create_project") + + job := &Job{ + Name: "create_project", + If: jobCondition.Render(), + RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Permissions: NewPermissionsContentsReadProjectsWrite().RenderToYAML(), + TimeoutMinutes: 10, + Steps: steps, + Needs: []string{mainJobName}, + } + + return job, nil +} diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 5a9278f196..905cabb460 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -25,11 +25,15 @@ var addReactionAndEditCommentScriptSource string //go:embed js/check_membership.cjs var checkMembershipScriptSource string +//go:embed js/create_project.cjs +var createProjectScriptSource string + // init registers scripts from js.go with the DefaultScriptRegistry func init() { DefaultScriptRegistry.Register("check_membership", checkMembershipScriptSource) DefaultScriptRegistry.Register("safe_outputs_mcp_server", safeOutputsMCPServerScriptSource) DefaultScriptRegistry.Register("update_project", updateProjectScriptSource) + DefaultScriptRegistry.Register("create_project", createProjectScriptSource) DefaultScriptRegistry.Register("interpolate_prompt", interpolatePromptScript) DefaultScriptRegistry.Register("assign_issue", assignIssueScriptSource) DefaultScriptRegistry.Register("add_copilot_reviewer", addCopilotReviewerScriptSource) @@ -144,6 +148,11 @@ func getUpdateProjectScript() string { return DefaultScriptRegistry.GetWithMode("update_project", RuntimeModeGitHubScript) } +// getCreateProjectScript returns the bundled create_project script +func getCreateProjectScript() string { + return DefaultScriptRegistry.GetWithMode("create_project", RuntimeModeGitHubScript) +} + //go:embed js/generate_footer.cjs var generateFooterScript string diff --git a/pkg/workflow/js/create_project.cjs b/pkg/workflow/js/create_project.cjs new file mode 100644 index 0000000000..ade491f1b7 --- /dev/null +++ b/pkg/workflow/js/create_project.cjs @@ -0,0 +1,189 @@ +const { loadAgentOutput } = require("./load_agent_output.cjs"); +const { generateCampaignId, normalizeProjectName } = require("./project_helpers.cjs"); + +/** + * @typedef {Object} CreateProjectOutput + * @property {"create_project"} type + * @property {string} project - Project title to create + * @property {string} [campaign_id] - Campaign tracking ID (auto-generated if not provided) + */ + +/** + * Create a new GitHub Project v2 + * @param {CreateProjectOutput} output - The create output + * @returns {Promise} + */ +async function createProject(output) { + // In actions/github-script, 'github' and 'context' are already available + const { owner, repo } = context.repo; + + // Normalize and validate project name + const normalizedProjectName = normalizeProjectName(output.project); + + const campaignId = output.campaign_id || generateCampaignId(normalizedProjectName); + + // Use the github client that's already configured with the token via github-token parameter + const githubClient = github; + + try { + // Step 1: Get repository and owner IDs + const repoResult = await githubClient.graphql( + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + id + owner { + id + __typename + } + } + }`, + { owner, repo } + ); + const repositoryId = repoResult.repository.id; + const ownerId = repoResult.repository.owner.id; + const ownerType = repoResult.repository.owner.__typename; + + // Step 2: Check if owner is a User + if (ownerType === "User") { + core.error(`Cannot create projects on user accounts. Create the project manually at https://github.com/users/${owner}/projects/new.`); + throw new Error(`Cannot create project "${output.project}" on user account.`); + } + + // Step 3: Check if project already exists + const ownerQuery = `query($login: String!) { + organization(login: $login) { + projectsV2(first: 100) { + nodes { + id + title + number + } + } + } + }`; + + const ownerProjectsResult = await githubClient.graphql(ownerQuery, { login: owner }); + const ownerProjects = ownerProjectsResult.organization.projectsV2.nodes; + + const existingProject = ownerProjects.find(p => p.title === normalizedProjectName); + if (existingProject) { + core.info(`✓ Project already exists: ${existingProject.title} (#${existingProject.number})`); + core.setOutput("project-id", existingProject.id); + core.setOutput("project-number", existingProject.number); + core.setOutput("campaign-id", campaignId); + + // Link project to repository if not already linked + try { + await githubClient.graphql( + `mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { + projectId: $projectId, + repositoryId: $repositoryId + }) { + repository { + id + } + } + }`, + { projectId: existingProject.id, repositoryId } + ); + } catch (linkError) { + if (!linkError.message || !linkError.message.includes("already linked")) { + core.warning(`Could not link project: ${linkError.message}`); + } + } + return; + } + + // Step 4: Create new project (organization only) + const createResult = await githubClient.graphql( + `mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: { + ownerId: $ownerId, + title: $title + }) { + projectV2 { + id + title + url + number + } + } + }`, + { + ownerId: ownerId, + title: normalizedProjectName, + } + ); + + const newProject = createResult.createProjectV2.projectV2; + + // Step 5: Link project to repository + await githubClient.graphql( + `mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { + projectId: $projectId, + repositoryId: $repositoryId + }) { + repository { + id + } + } + }`, + { projectId: newProject.id, repositoryId } + ); + + core.info(`✓ Created project: ${newProject.title}`); + core.setOutput("project-id", newProject.id); + core.setOutput("project-number", newProject.number); + core.setOutput("project-url", newProject.url); + core.setOutput("campaign-id", campaignId); + } catch (error) { + // Provide helpful error messages for common permission issues + if (error.message && error.message.includes("does not have permission to create projects")) { + core.error( + `Failed to create project: ${error.message}\n\n` + + `Troubleshooting:\n` + + ` â€ĸ Create the project manually at https://github.com/orgs/${owner}/projects/new.\n` + + ` â€ĸ Or supply a PAT with project scope via github-token configuration.\n` + + ` â€ĸ Ensure the workflow grants projects: write.\n` + ); + } else { + core.error(`Failed to create project: ${error.message}`); + } + throw error; + } +} + +async function main() { + const result = loadAgentOutput(); + if (!result.success) { + return; + } + + const createProjectItems = result.items.filter(item => item.type === "create_project"); + if (createProjectItems.length === 0) { + return; + } + + // Process all create_project items + for (let i = 0; i < createProjectItems.length; i++) { + const output = createProjectItems[i]; + try { + await createProject(output); + } catch (error) { + core.error(`Failed to process item ${i + 1}: ${error.message}`); + // Continue processing remaining items even if one fails + } + } +} + +// Export for testing +if (typeof module !== "undefined" && module.exports) { + module.exports = { createProject, main }; +} + +// Run automatically in GitHub Actions (module undefined) or when executed directly via Node +if (typeof module === "undefined" || require.main === module) { + main(); +} diff --git a/pkg/workflow/js/create_project.test.cjs b/pkg/workflow/js/create_project.test.cjs new file mode 100644 index 0000000000..5bfcfa0840 --- /dev/null +++ b/pkg/workflow/js/create_project.test.cjs @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; + +let createProject; +let generateCampaignId; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + getInput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockGithub = { + rest: {}, + graphql: vi.fn(), +}; + +const mockContext = { + runId: 12345, + repo: { + owner: "testowner", + repo: "testrepo", + }, + payload: { + repository: { + html_url: "https://github.com/testowner/testrepo", + }, + }, +}; + +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +beforeAll(async () => { + const mod = await import("./create_project.cjs"); + const exports = mod.default || mod; + createProject = exports.createProject; + + // Import generateCampaignId from helper + const helperMod = await import("./project_helpers.cjs"); + const helperExports = helperMod.default || helperMod; + generateCampaignId = helperExports.generateCampaignId; +}); + +function clearMock(fn) { + if (fn && typeof fn.mockClear === "function") { + fn.mockClear(); + } +} + +function clearCoreMocks() { + clearMock(mockCore.debug); + clearMock(mockCore.info); + clearMock(mockCore.notice); + clearMock(mockCore.warning); + clearMock(mockCore.error); + clearMock(mockCore.setFailed); + clearMock(mockCore.setOutput); + clearMock(mockCore.exportVariable); + clearMock(mockCore.getInput); + clearMock(mockCore.summary.addRaw); + clearMock(mockCore.summary.write); +} + +beforeEach(() => { + mockGithub.graphql.mockReset(); + clearCoreMocks(); + vi.useRealTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const repoResponse = (ownerType = "Organization") => ({ + repository: { + id: "repo123", + owner: { + id: ownerType === "User" ? "owner-user-123" : "owner123", + __typename: ownerType, + }, + }, +}); + +const ownerProjectsResponse = nodes => ({ organization: { projectsV2: { nodes } } }); + +const linkResponse = { linkProjectV2ToRepository: { repository: { id: "repo123" } } }; + +function queueResponses(responses) { + responses.forEach(response => { + mockGithub.graphql.mockResolvedValueOnce(response); + }); +} + +function getOutput(name) { + const call = mockCore.setOutput.mock.calls.find(([key]) => key === name); + return call ? call[1] : undefined; +} + +describe("generateCampaignId", () => { + it("builds a slug with a timestamp suffix", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Bug Bash Q1 2025"); + expect(id).toBe("bug-bash-q1-2025-m4syw5xc"); + nowSpy.mockRestore(); + }); +}); + +describe("createProject", () => { + it("creates a new project when none exist", async () => { + const output = { type: "create_project", project: "New Campaign" }; + + queueResponses([ + repoResponse(), + ownerProjectsResponse([]), + { + createProjectV2: { + projectV2: { + id: "project123", + title: "New Campaign", + url: "https://github.com/orgs/testowner/projects/1", + number: 1, + }, + }, + }, + linkResponse, + ]); + + await createProject(output); + + expect(mockCore.info).toHaveBeenCalledWith("✓ Created project: New Campaign"); + expect(getOutput("project-id")).toBe("project123"); + expect(getOutput("project-number")).toBe(1); + expect(getOutput("project-url")).toBe("https://github.com/orgs/testowner/projects/1"); + expect(getOutput("campaign-id")).toMatch(/^new-campaign-[a-z0-9]{8}$/); + + expect(mockGithub.graphql).toHaveBeenCalledWith( + expect.stringContaining("createProjectV2"), + expect.objectContaining({ ownerId: "owner123", title: "New Campaign" }) + ); + }); + + it("respects a custom campaign id", async () => { + const output = { type: "create_project", project: "Custom Campaign", campaign_id: "custom-id-2025" }; + + queueResponses([ + repoResponse(), + ownerProjectsResponse([]), + { + createProjectV2: { + projectV2: { + id: "project456", + title: "Custom Campaign", + url: "https://github.com/orgs/testowner/projects/2", + number: 2, + }, + }, + }, + linkResponse, + ]); + + await createProject(output); + + expect(getOutput("campaign-id")).toBe("custom-id-2025"); + expect(mockCore.info).toHaveBeenCalledWith("✓ Created project: Custom Campaign"); + }); + + it("handles existing project gracefully", async () => { + const output = { type: "create_project", project: "Existing Campaign" }; + + queueResponses([ + repoResponse(), + ownerProjectsResponse([{ id: "existing-project-123", title: "Existing Campaign", number: 5 }]), + linkResponse, + ]); + + await createProject(output); + + const createCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2")); + expect(createCall).toBeUndefined(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✓ Project already exists")); + expect(getOutput("project-id")).toBe("existing-project-123"); + expect(getOutput("project-number")).toBe(5); + }); + + it("throws error for user accounts", async () => { + const output = { type: "create_project", project: "User Project" }; + + queueResponses([repoResponse("User")]); + + await expect(createProject(output)).rejects.toThrow(/Cannot create project.*on user account/); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Cannot create projects on user accounts")); + }); + + it("throws error for invalid project name", async () => { + const output = { type: "create_project", project: null }; + + await expect(createProject(output)).rejects.toThrow(/Invalid project name/); + }); + + it("surfaces project creation failures with helpful messages", async () => { + const output = { type: "create_project", project: "Fail Project" }; + + queueResponses([repoResponse(), ownerProjectsResponse([])]); + + mockGithub.graphql.mockRejectedValueOnce(new Error("does not have permission to create projects")); + + await expect(createProject(output)).rejects.toThrow(/permission to create projects/); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to create project")); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Troubleshooting")); + }); + + it("warns when linking project fails (non-fatal)", async () => { + const output = { type: "create_project", project: "Existing Campaign" }; + + queueResponses([repoResponse(), ownerProjectsResponse([{ id: "existing-project-123", title: "Existing Campaign", number: 5 }])]); + + mockGithub.graphql.mockRejectedValueOnce(new Error("Link failed")); + + await createProject(output); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not link project")); + }); +}); diff --git a/pkg/workflow/js/project_helpers.cjs b/pkg/workflow/js/project_helpers.cjs new file mode 100644 index 0000000000..a8404232cf --- /dev/null +++ b/pkg/workflow/js/project_helpers.cjs @@ -0,0 +1,44 @@ +/** + * Helper functions for GitHub Projects v2 operations + */ + +/** + * Generate a campaign ID from project name + * @param {string} projectName - The project/campaign name + * @returns {string} Campaign ID in format: slug-timestamp (e.g., "perf-q1-2025-a3f2b4c8") + */ +function generateCampaignId(projectName) { + // Create slug from project name + const slug = projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .substring(0, 30); + + // Add short timestamp hash for uniqueness + const timestamp = Date.now().toString(36).substring(0, 8); + + return `${slug}-${timestamp}`; +} + +/** + * Normalize project name by trimming whitespace + * @param {string} projectName - The project name to normalize + * @returns {string} Normalized project name + */ +function normalizeProjectName(projectName) { + if (!projectName || typeof projectName !== "string") { + throw new Error( + `Invalid project name: expected string, got ${typeof projectName}. The "project" field is required and must be a project title.` + ); + } + return projectName.trim(); +} + +// Export for testing and use in other modules +if (typeof module !== "undefined" && module.exports) { + module.exports = { + generateCampaignId, + normalizeProjectName, + }; +} diff --git a/pkg/workflow/js/project_helpers.test.cjs b/pkg/workflow/js/project_helpers.test.cjs new file mode 100644 index 0000000000..8ecca8830d --- /dev/null +++ b/pkg/workflow/js/project_helpers.test.cjs @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +let generateCampaignId; +let normalizeProjectName; + +beforeEach(async () => { + const mod = await import("./project_helpers.cjs"); + const exports = mod.default || mod; + generateCampaignId = exports.generateCampaignId; + normalizeProjectName = exports.normalizeProjectName; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("generateCampaignId", () => { + it("builds a slug with a timestamp suffix", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Bug Bash Q1 2025"); + expect(id).toBe("bug-bash-q1-2025-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("handles project names with special characters", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Project! @#$ %^& *()"); + expect(id).toBe("project-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("removes leading and trailing dashes", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("---Project---"); + expect(id).toBe("project-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("truncates long project names to 30 characters", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const longName = "This is a very long project name that exceeds thirty characters"; + const id = generateCampaignId(longName); + expect(id).toBe("this-is-a-very-long-project-na-m4syw5xc"); + expect(id.length).toBeLessThanOrEqual(39); // 30 + dash + 8 char timestamp + nowSpy.mockRestore(); + }); + + it("handles empty strings", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId(""); + expect(id).toBe("-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("handles strings with only special characters", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("!@#$%^&*()"); + expect(id).toBe("-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("preserves numbers in project names", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Project 2025 Q1"); + expect(id).toBe("project-2025-q1-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("converts uppercase to lowercase", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("PROJECT NAME"); + expect(id).toBe("project-name-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("collapses multiple spaces/special chars into single dash", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Project Name!!!"); + expect(id).toBe("project-name-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("generates unique timestamps for different calls", () => { + const id1 = generateCampaignId("Test Project"); + const id2 = generateCampaignId("Test Project"); + + // Extract timestamps (last 8 characters) + const timestamp1 = id1.slice(-8); + const timestamp2 = id2.slice(-8); + + // Timestamps might be the same if calls are very close, but should be valid base36 + expect(/^[a-z0-9]{8}$/.test(timestamp1)).toBe(true); + expect(/^[a-z0-9]{8}$/.test(timestamp2)).toBe(true); + }); + + it("handles unicode characters", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("Projet ÊtÊ 2025"); + // Non-ASCII characters should be removed + expect(id).toMatch(/^projet-t-2025-m4syw5xc$/); + nowSpy.mockRestore(); + }); + + it("handles project names with underscores", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("project_name_test"); + expect(id).toBe("project-name-test-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("handles project names with dots", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("project.name.test"); + expect(id).toBe("project-name-test-m4syw5xc"); + nowSpy.mockRestore(); + }); + + it("handles mixed case and special characters", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1734470400000); + const id = generateCampaignId("My_Project-2025.Q1"); + expect(id).toBe("my-project-2025-q1-m4syw5xc"); + nowSpy.mockRestore(); + }); +}); + +describe("normalizeProjectName", () => { + it("trims whitespace from project name", () => { + expect(normalizeProjectName(" Test Project ")).toBe("Test Project"); + }); + + it("trims leading whitespace", () => { + expect(normalizeProjectName(" Test Project")).toBe("Test Project"); + }); + + it("trims trailing whitespace", () => { + expect(normalizeProjectName("Test Project ")).toBe("Test Project"); + }); + + it("preserves internal whitespace", () => { + expect(normalizeProjectName("Test Project Name")).toBe("Test Project Name"); + }); + + it("handles tabs and newlines", () => { + expect(normalizeProjectName("\t\nTest Project\n\t")).toBe("Test Project"); + }); + + it("returns empty string if input is empty after trim", () => { + expect(normalizeProjectName(" ")).toBe(""); + }); + + it("preserves valid project names without whitespace", () => { + expect(normalizeProjectName("TestProject")).toBe("TestProject"); + }); + + it("throws error for null input", () => { + expect(() => normalizeProjectName(null)).toThrow(/Invalid project name/); + }); + + it("throws error for undefined input", () => { + expect(() => normalizeProjectName(undefined)).toThrow(/Invalid project name/); + }); + + it("throws error for non-string input", () => { + expect(() => normalizeProjectName(123)).toThrow(/Invalid project name/); + }); + + it("throws error for object input", () => { + expect(() => normalizeProjectName({})).toThrow(/Invalid project name/); + }); + + it("throws error for array input", () => { + expect(() => normalizeProjectName([])).toThrow(/Invalid project name/); + }); + + it("handles project names with special characters (no normalization beyond trim)", () => { + expect(normalizeProjectName(" Project-2025! ")).toBe("Project-2025!"); + }); + + it("handles very long project names", () => { + const longName = "A".repeat(1000); + expect(normalizeProjectName(longName)).toBe(longName); + }); +}); diff --git a/pkg/workflow/js/update_project.cjs b/pkg/workflow/js/update_project.cjs index d39d29f46d..af26517568 100644 --- a/pkg/workflow/js/update_project.cjs +++ b/pkg/workflow/js/update_project.cjs @@ -104,7 +104,7 @@ async function updateProject(output) { const ownerId = repoResult.repository.owner.id; const ownerType = repoResult.repository.owner.__typename; - // Step 2: Find existing project or create it + // Step 2: Find existing project (project must already exist) let projectId; let projectNumber; let existingProject = null; @@ -148,66 +148,21 @@ async function updateProject(output) { return p.title === parsedProjectName; }); - // If found at owner level, ensure it's linked to the repository - if (existingProject) { - try { - await githubClient.graphql( - `mutation($projectId: ID!, $repositoryId: ID!) { - linkProjectV2ToRepository(input: { - projectId: $projectId, - repositoryId: $repositoryId - }) { - repository { - id - } - } - }`, - { projectId: existingProject.id, repositoryId } - ); - } catch (linkError) { - if (!linkError.message || !linkError.message.includes("already linked")) { - core.warning(`Could not link project: ${linkError.message}`); - } - } + if (!existingProject) { + const projectDisplay = parsedProjectNumber ? `project #${parsedProjectNumber}` : `project "${parsedProjectName}"`; + const createHint = + ownerType === "User" + ? `Create it manually at https://github.com/users/${owner}/projects/new or use create-project safe-output.` + : `Use create-project safe-output to create it first.`; + core.error(`Cannot find ${projectDisplay}. ${createHint}`); + throw new Error(`Project not found: ${projectDisplay}`); } - if (existingProject) { - projectId = existingProject.id; - projectNumber = existingProject.number; - } else { - // Check if owner is a User before attempting to create - if (ownerType === "User") { - const projectDisplay = parsedProjectNumber ? `project #${parsedProjectNumber}` : `project "${parsedProjectName}"`; - core.error(`Cannot find ${projectDisplay}. Create it manually at https://github.com/users/${owner}/projects/new.`); - throw new Error(`Cannot find ${projectDisplay} on user account.`); - } + projectId = existingProject.id; + projectNumber = existingProject.number; - // Create new project (organization only) - const createResult = await githubClient.graphql( - `mutation($ownerId: ID!, $title: String!) { - createProjectV2(input: { - ownerId: $ownerId, - title: $title - }) { - projectV2 { - id - title - url - number - } - } - }`, - { - ownerId: ownerId, // Use owner ID (org/user), not repository ID - title: output.project, - } - ); - - const newProject = createResult.createProjectV2.projectV2; - projectId = newProject.id; - projectNumber = newProject.number; - - // Link project to repository + // If found at owner level, ensure it's linked to the repository + try { await githubClient.graphql( `mutation($projectId: ID!, $repositoryId: ID!) { linkProjectV2ToRepository(input: { @@ -219,14 +174,12 @@ async function updateProject(output) { } } }`, - { projectId, repositoryId } + { projectId: existingProject.id, repositoryId } ); - - core.info(`✓ Created project: ${newProject.title}`); - core.setOutput("project-id", projectId); - core.setOutput("project-number", projectNumber); - core.setOutput("project-url", newProject.url); - core.setOutput("campaign-id", campaignId); + } catch (linkError) { + if (!linkError.message || !linkError.message.includes("already linked")) { + core.warning(`Could not link project: ${linkError.message}`); + } } // Step 3: If issue or PR specified, add/update it on the board diff --git a/pkg/workflow/js/update_project.test.cjs b/pkg/workflow/js/update_project.test.cjs index 9df08b0b5b..2944ee8c6b 100644 --- a/pkg/workflow/js/update_project.test.cjs +++ b/pkg/workflow/js/update_project.test.cjs @@ -174,62 +174,28 @@ describe("generateCampaignId", () => { }); describe("updateProject", () => { - it("creates a new project when none exist", async () => { - const output = { type: "update_project", project: "New Campaign" }; + it("requires project to exist", async () => { + const output = { type: "update_project", project: "NonExistent Campaign" }; - queueResponses([ - repoResponse(), - ownerProjectsResponse([]), - { - createProjectV2: { - projectV2: { - id: "project123", - title: "New Campaign", - url: "https://github.com/orgs/testowner/projects/1", - number: 1, - }, - }, - }, - linkResponse, - ]); - - await updateProject(output); - - expect(mockCore.info).toHaveBeenCalledWith("✓ Created project: New Campaign"); - expect(getOutput("project-id")).toBe("project123"); - expect(getOutput("project-number")).toBe(1); - expect(getOutput("project-url")).toBe("https://github.com/orgs/testowner/projects/1"); - expect(getOutput("campaign-id")).toMatch(/^new-campaign-[a-z0-9]{8}$/); + queueResponses([repoResponse(), ownerProjectsResponse([])]); - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("createProjectV2"), - expect.objectContaining({ ownerId: "owner123", title: "New Campaign" }) - ); + await expect(updateProject(output)).rejects.toThrow(/Project not found/); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Cannot find")); }); - it("respects a custom campaign id", async () => { + it("finds an existing project by title and uses custom campaign id", async () => { const output = { type: "update_project", project: "Custom Campaign", campaign_id: "custom-id-2025" }; queueResponses([ repoResponse(), - ownerProjectsResponse([]), - { - createProjectV2: { - projectV2: { - id: "project456", - title: "Custom Campaign", - url: "https://github.com/orgs/testowner/projects/2", - number: 2, - }, - }, - }, + ownerProjectsResponse([{ id: "existing-project-456", title: "Custom Campaign", number: 2 }]), linkResponse, ]); await updateProject(output); - expect(getOutput("campaign-id")).toBe("custom-id-2025"); - expect(mockCore.info).toHaveBeenCalledWith("✓ Created project: Custom Campaign"); + const createCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2")); + expect(createCall).toBeUndefined(); }); it("finds an existing project by title", async () => { @@ -460,14 +426,12 @@ describe("updateProject", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to add campaign label")); }); - it("surfaces project creation failures", async () => { - const output = { type: "update_project", project: "Fail Project" }; + it("throws error when project does not exist", async () => { + const output = { type: "update_project", project: "Nonexistent Project" }; queueResponses([repoResponse(), ownerProjectsResponse([])]); - mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: Insufficient permissions")); - - await expect(updateProject(output)).rejects.toThrow(/Insufficient permissions/); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to manage project")); + await expect(updateProject(output)).rejects.toThrow(/Project not found/); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Cannot find")); }); }); diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index e5a917187a..789a0c575f 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -215,6 +215,9 @@ func GetEnabledSafeOutputToolNames(safeOutputs *SafeOutputsConfig) []string { if safeOutputs.UpdateRelease != nil { tools = append(tools, "update_release") } + if safeOutputs.CreateProjects != nil { + tools = append(tools, "create_project") + } if safeOutputs.UpdateProjects != nil { tools = append(tools, "update_project") } @@ -272,7 +275,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.CreateAgentTasks = agentTaskConfig } - // Handle update-project (smart project board management) + // Handle create-project (GitHub Projects v2 board creation) + createProjectConfig := c.parseCreateProjectConfig(outputMap) + if createProjectConfig != nil { + config.CreateProjects = createProjectConfig + } + + // Handle update-project (add items to boards, update fields) updateProjectConfig := c.parseUpdateProjectConfig(outputMap) if updateProjectConfig != nil { config.UpdateProjects = updateProjectConfig @@ -1149,6 +1158,16 @@ func generateSafeOutputsConfig(data *WorkflowData) string { missingToolConfig["max"] = maxValue safeOutputsConfig["missing_tool"] = missingToolConfig } + if data.SafeOutputs.CreateProjects != nil { + createProjectConfig := map[string]any{} + // Always include max (use configured value or default) + maxValue := 1 // default + if data.SafeOutputs.CreateProjects.Max > 0 { + maxValue = data.SafeOutputs.CreateProjects.Max + } + createProjectConfig["max"] = maxValue + safeOutputsConfig["create_project"] = createProjectConfig + } if data.SafeOutputs.UpdateProjects != nil { updateProjectConfig := map[string]any{} // Always include max (use configured value or default)