diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 9f444ba3076..64b15cf5cb1 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -24,6 +24,7 @@ # on: # issues: # types: [opened] +# lock-for-agent: true # issue_comment: # types: [created] # pull_request_review_comment: @@ -249,6 +250,7 @@ name: "AI Moderator" types: - created issues: + lock-for-agent: true types: - opened pull_request_review_comment: @@ -279,6 +281,7 @@ jobs: outputs: comment_id: "" comment_repo: "" + issue_locked: ${{ steps.lock-issue.outputs.locked }} text: ${{ steps.compute-text.outputs.text }} steps: - name: Check workflow file timestamps @@ -767,6 +770,48 @@ jobs: } } await main(); + - name: Lock issue for agent workflow + id: lock-issue + if: github.event.issue.number + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + async function main() { + const issueNumber = context.issue.number; + if (!issueNumber) { + core.setFailed("Issue number not found in context"); + return; + } + const owner = context.repo.owner; + const repo = context.repo.repo; + try { + core.info(`Checking if issue #${issueNumber} is already locked`); + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + if (issue.locked) { + core.info(`ℹ️ Issue #${issueNumber} is already locked, skipping lock operation`); + core.setOutput("locked", "false"); + return; + } + core.info(`Locking issue #${issueNumber} for agent workflow execution`); + await github.rest.issues.lock({ + owner, + repo, + issue_number: issueNumber, + }); + core.info(`✅ Successfully locked issue #${issueNumber}`); + core.setOutput("locked", "true"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to lock issue: ${errorMessage}`); + core.setFailed(`Failed to lock issue #${issueNumber}: ${errorMessage}`); + core.setOutput("locked", "false"); + } + } + await main(); add_labels: needs: agent @@ -7437,6 +7482,45 @@ jobs: main().catch(error => { core.setFailed(error instanceof Error ? error.message : String(error)); }); + - name: Unlock issue after agent workflow + id: unlock-issue + if: (always()) && ((github.event.issue.number) && (needs.activation.outputs.issue_locked == 'true')) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + async function main() { + const issueNumber = context.issue.number; + if (!issueNumber) { + core.setFailed("Issue number not found in context"); + return; + } + const owner = context.repo.owner; + const repo = context.repo.repo; + try { + core.info(`Checking if issue #${issueNumber} is locked`); + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + if (!issue.locked) { + core.info(`ℹ️ Issue #${issueNumber} is not locked, skipping unlock operation`); + return; + } + core.info(`Unlocking issue #${issueNumber} after agent workflow execution`); + await github.rest.issues.unlock({ + owner, + repo, + issue_number: issueNumber, + }); + core.info(`✅ Successfully unlocked issue #${issueNumber}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to unlock issue: ${errorMessage}`); + core.setFailed(`Failed to unlock issue #${issueNumber}: ${errorMessage}`); + } + } + await main(); hide_comment: needs: agent diff --git a/.github/workflows/ai-moderator.md b/.github/workflows/ai-moderator.md index bd532bb215e..601ffa301db 100644 --- a/.github/workflows/ai-moderator.md +++ b/.github/workflows/ai-moderator.md @@ -3,6 +3,7 @@ timeout-minutes: 5 on: issues: types: [opened] + lock-for-agent: true issue_comment: types: [created] pull_request_review_comment: diff --git a/.github/workflows/archie.lock.yml b/.github/workflows/archie.lock.yml index b7c11221f1a..8fae1738a51 100644 --- a/.github/workflows/archie.lock.yml +++ b/.github/workflows/archie.lock.yml @@ -1115,6 +1115,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index 99e50e83f65..ed581403316 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -1012,6 +1012,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index fe26ccc28b5..69723f68dc1 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -1156,6 +1156,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index ed9bee5bc0c..a0559ee3eab 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -1220,6 +1220,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/craft.lock.yml b/.github/workflows/craft.lock.yml index 678ac8056fb..27cf1f0e054 100644 --- a/.github/workflows/craft.lock.yml +++ b/.github/workflows/craft.lock.yml @@ -1170,6 +1170,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 0a817219396..91a905f5eba 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -1051,6 +1051,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index ef750ad717d..439fdb6d159 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -938,6 +938,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/mergefest.lock.yml b/.github/workflows/mergefest.lock.yml index ce023f148c5..be70b0d3310 100644 --- a/.github/workflows/mergefest.lock.yml +++ b/.github/workflows/mergefest.lock.yml @@ -830,6 +830,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index 234e1304fc0..fecf698f060 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -1103,6 +1103,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 844e398a0d1..29804eb87fb 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -1090,6 +1090,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index daffb570774..8ad2fc25826 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1131,6 +1131,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 69fe97cc616..f44cd304dac 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -994,6 +994,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 9791fb84ec3..90f864f8615 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -1341,6 +1341,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 6f9f17eee62..dc9aa05426a 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -1302,6 +1302,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 66e314f993f..8b94b031b09 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -697,6 +697,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 8e1714cf574..9be79ca261c 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -581,6 +581,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index bad53ad5cee..0c497586ec9 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -611,6 +611,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index 253c45a5adf..6938239aba4 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -660,6 +660,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-copilot-safe-inputs.lock.yml b/.github/workflows/smoke-copilot-safe-inputs.lock.yml index 5b8e39db53b..47e6a803f96 100644 --- a/.github/workflows/smoke-copilot-safe-inputs.lock.yml +++ b/.github/workflows/smoke-copilot-safe-inputs.lock.yml @@ -588,6 +588,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index a865c18772e..68539e57222 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -568,6 +568,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index 50e6cfe9ed8..369bb721cb7 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -903,6 +903,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/speckit-dispatcher.lock.yml b/.github/workflows/speckit-dispatcher.lock.yml index c4b2d0dcbde..efe968dbd86 100644 --- a/.github/workflows/speckit-dispatcher.lock.yml +++ b/.github/workflows/speckit-dispatcher.lock.yml @@ -1318,6 +1318,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index e429a036a28..64d8c8cf1ee 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -636,6 +636,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 7596fda7869..7fffa89576d 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -927,6 +927,10 @@ jobs: const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; let commentBody = workflowLinkText; + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } if (workflowId) { commentBody += `\n\n`; } diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index e8a9dca1c64..b63b360f2a0 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -978,6 +978,11 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate steps = append(steps, fmt.Sprintf(" GH_AW_TRACKER_ID: %q\n", data.TrackerID)) } + // Add lock-for-agent status if enabled + if data.LockForAgent { + steps = append(steps, " GH_AW_LOCK_FOR_AGENT: \"true\"\n") + } + // Pass custom messages config if present (for custom run-started messages) if data.SafeOutputs != nil && data.SafeOutputs.Messages != nil { messagesJSON, err := serializeMessagesConfig(data.SafeOutputs.Messages) @@ -1002,6 +1007,31 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate outputs["comment_repo"] = "${{ steps.react.outputs.comment-repo }}" } + // Add lock step if lock-for-agent is enabled + if data.LockForAgent { + // Build condition: only lock if this is an issue context + lockCondition := BuildPropertyAccess("github.event.issue.number") + + steps = append(steps, " - name: Lock issue for agent workflow\n") + steps = append(steps, " id: lock-issue\n") + steps = append(steps, fmt.Sprintf(" if: %s\n", lockCondition.Render())) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add the lock-issue script + formattedScript := FormatJavaScriptForYAML(lockIssueScript) + steps = append(steps, formattedScript...) + + // Add output for tracking if issue was locked + outputs["issue_locked"] = "${{ steps.lock-issue.outputs.locked }}" + + // Add lock message to reaction comment if reaction is enabled + if data.AIReaction != "" && data.AIReaction != "none" { + compilerJobsLog.Print("Adding lock notification to reaction message") + } + } + // Always declare comment_id and comment_repo outputs to avoid actionlint errors // These will be empty if no reaction is configured, and the scripts handle empty values gracefully // Use plain empty strings (quoted) to avoid triggering security scanners like zizmor diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index 308c57652e3..4479a980cbf 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -48,6 +48,18 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work workflowData.AIReaction = reactionStr } + // Extract lock-for-agent from on.issues section + if issuesValue, hasIssues := onMap["issues"]; hasIssues { + if issuesMap, ok := issuesValue.(map[string]any); ok { + if lockForAgent, hasLockForAgent := issuesMap["lock-for-agent"]; hasLockForAgent { + if lockBool, ok := lockForAgent.(bool); ok { + workflowData.LockForAgent = lockBool + compilerSafeOutputsLog.Printf("lock-for-agent enabled: %v", lockBool) + } + } + } + } + if _, hasCommandKey := onMap["command"]; hasCommandKey { hasCommand = true // Set default command to filename if not specified in the command section diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 0ae45f1f0d4..890a0760969 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -216,6 +216,7 @@ type WorkflowData struct { CommandEvents []string // events where command should be active (nil = all events) CommandOtherEvents map[string]any // for merging command with other events AIReaction string // AI reaction type like "eyes", "heart", etc. + LockForAgent bool // whether to lock the issue during agent workflow execution Jobs map[string]any // custom job configurations with dependencies Cache string // cache configuration NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} diff --git a/pkg/workflow/js/add_reaction_and_edit_comment.cjs b/pkg/workflow/js/add_reaction_and_edit_comment.cjs index e1cb09b3055..bfbf58ea654 100644 --- a/pkg/workflow/js/add_reaction_and_edit_comment.cjs +++ b/pkg/workflow/js/add_reaction_and_edit_comment.cjs @@ -334,6 +334,12 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { let commentBody = workflowLinkText; + // Add lock notice if lock-for-agent is enabled for issues + const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; + if (lockForAgent && eventName === "issues") { + commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + } + // Add workflow-id marker if available if (workflowId) { commentBody += `\n\n`; diff --git a/pkg/workflow/js/lock-issue.cjs b/pkg/workflow/js/lock-issue.cjs index 764e6a864eb..ee79548cb07 100644 --- a/pkg/workflow/js/lock-issue.cjs +++ b/pkg/workflow/js/lock-issue.cjs @@ -19,9 +19,23 @@ async function main() { const owner = context.repo.owner; const repo = context.repo.repo; - core.info(`Locking issue #${issueNumber} for agent workflow execution`); - try { + // Check if issue is already locked + core.info(`Checking if issue #${issueNumber} is already locked`); + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + if (issue.locked) { + core.info(`ℹ️ Issue #${issueNumber} is already locked, skipping lock operation`); + core.setOutput("locked", "false"); + return; + } + + core.info(`Locking issue #${issueNumber} for agent workflow execution`); + // Lock the issue without providing a lock_reason parameter await github.rest.issues.lock({ owner, @@ -30,10 +44,13 @@ async function main() { }); core.info(`✅ Successfully locked issue #${issueNumber}`); + // Set output to indicate the issue was locked and needs to be unlocked + core.setOutput("locked", "true"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); core.error(`Failed to lock issue: ${errorMessage}`); core.setFailed(`Failed to lock issue #${issueNumber}: ${errorMessage}`); + core.setOutput("locked", "false"); } } diff --git a/pkg/workflow/js/lock-issue.test.cjs b/pkg/workflow/js/lock-issue.test.cjs new file mode 100644 index 00000000000..4bf586ab005 --- /dev/null +++ b/pkg/workflow/js/lock-issue.test.cjs @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), +}; + +const mockGithub = { + rest: { + issues: { + get: vi.fn(), + lock: vi.fn(), + }, + }, +}; + +const mockContext = { + eventName: "issues", + runId: 12345, + repo: { + owner: "testowner", + repo: "testrepo", + }, + issue: { + number: 42, + }, + payload: { + issue: { + number: 42, + }, + repository: { + html_url: "https://github.com/testowner/testrepo", + }, + }, +}; + +// Set up global mocks before importing the module +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe("lock-issue", () => { + let lockIssueScript; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Reset context to default state + global.context.eventName = "issues"; + global.context.issue = { number: 42 }; + global.context.payload.issue = { number: 42 }; + + // Read the script content + const scriptPath = path.join(process.cwd(), "lock-issue.cjs"); + lockIssueScript = fs.readFileSync(scriptPath, "utf8"); + }); + + it("should lock issue successfully", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + // Mock successful lock + mockGithub.rest.issues.lock.mockResolvedValue({ + status: 204, + }); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + expect(mockGithub.rest.issues.lock).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #42 is already locked"); + expect(mockCore.info).toHaveBeenCalledWith("Locking issue #42 for agent workflow execution"); + expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully locked issue #42"); + expect(mockCore.setOutput).toHaveBeenCalledWith("locked", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should skip locking if issue is already locked", async () => { + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: true, + }, + }); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + // Should not call lock since issue is already locked + expect(mockGithub.rest.issues.lock).not.toHaveBeenCalled(); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #42 is already locked"); + expect(mockCore.info).toHaveBeenCalledWith("ℹ️ Issue #42 is already locked, skipping lock operation"); + expect(mockCore.setOutput).toHaveBeenCalledWith("locked", "false"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fail when issue number is not found in context", async () => { + // Remove issue number from context + global.context.issue = {}; + delete global.context.payload.issue; + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockGithub.rest.issues.lock).not.toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalledWith("Issue number not found in context"); + }); + + it("should handle API errors gracefully", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + // Mock API error + const apiError = new Error("API rate limit exceeded"); + mockGithub.rest.issues.lock.mockRejectedValue(apiError); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockGithub.rest.issues.lock).toHaveBeenCalled(); + expect(mockCore.error).toHaveBeenCalledWith("Failed to lock issue: API rate limit exceeded"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to lock issue #42: API rate limit exceeded"); + expect(mockCore.setOutput).toHaveBeenCalledWith("locked", "false"); + }); + + it("should handle non-Error exceptions", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + // Mock non-Error exception + mockGithub.rest.issues.lock.mockRejectedValue("String error"); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockCore.error).toHaveBeenCalledWith("Failed to lock issue: String error"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to lock issue #42: String error"); + expect(mockCore.setOutput).toHaveBeenCalledWith("locked", "false"); + }); + + it("should work with different issue numbers", async () => { + // Change issue number + global.context.issue = { number: 100 }; + global.context.payload.issue = { number: 100 }; + + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 100, + locked: false, + }, + }); + + mockGithub.rest.issues.lock.mockResolvedValue({ + status: 204, + }); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + expect(mockGithub.rest.issues.lock).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 100, + }); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #100 is already locked"); + expect(mockCore.info).toHaveBeenCalledWith("Locking issue #100 for agent workflow execution"); + expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully locked issue #100"); + }); + + it("should not provide a lock reason", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + mockGithub.rest.issues.lock.mockResolvedValue({ + status: 204, + }); + + // Execute the script + await eval(`(async () => { ${lockIssueScript} })()`); + + const lockCall = mockGithub.rest.issues.lock.mock.calls[0][0]; + + // Verify no lock_reason is provided + expect(lockCall).not.toHaveProperty("lock_reason"); + expect(lockCall).toEqual({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + }); +}); diff --git a/pkg/workflow/js/unlock-issue.cjs b/pkg/workflow/js/unlock-issue.cjs index c2d46d41b4f..6aa7f10f1c6 100644 --- a/pkg/workflow/js/unlock-issue.cjs +++ b/pkg/workflow/js/unlock-issue.cjs @@ -19,9 +19,22 @@ async function main() { const owner = context.repo.owner; const repo = context.repo.repo; - core.info(`Unlocking issue #${issueNumber} after agent workflow execution`); - try { + // Check if issue is locked + core.info(`Checking if issue #${issueNumber} is locked`); + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + if (!issue.locked) { + core.info(`ℹ️ Issue #${issueNumber} is not locked, skipping unlock operation`); + return; + } + + core.info(`Unlocking issue #${issueNumber} after agent workflow execution`); + // Unlock the issue await github.rest.issues.unlock({ owner, diff --git a/pkg/workflow/js/unlock-issue.test.cjs b/pkg/workflow/js/unlock-issue.test.cjs new file mode 100644 index 00000000000..d912d10f5a5 --- /dev/null +++ b/pkg/workflow/js/unlock-issue.test.cjs @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), +}; + +const mockGithub = { + rest: { + issues: { + get: vi.fn(), + unlock: vi.fn(), + }, + }, +}; + +const mockContext = { + eventName: "issues", + runId: 12345, + repo: { + owner: "testowner", + repo: "testrepo", + }, + issue: { + number: 42, + }, + payload: { + issue: { + number: 42, + }, + repository: { + html_url: "https://github.com/testowner/testrepo", + }, + }, +}; + +// Set up global mocks before importing the module +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe("unlock-issue", () => { + let unlockIssueScript; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Reset context to default state + global.context.eventName = "issues"; + global.context.issue = { number: 42 }; + global.context.payload.issue = { number: 42 }; + + // Read the script content + const scriptPath = path.join(process.cwd(), "unlock-issue.cjs"); + unlockIssueScript = fs.readFileSync(scriptPath, "utf8"); + }); + + it("should unlock issue successfully", async () => { + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: true, + }, + }); + + // Mock successful unlock + mockGithub.rest.issues.unlock.mockResolvedValue({ + status: 204, + }); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + expect(mockGithub.rest.issues.unlock).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #42 is locked"); + expect(mockCore.info).toHaveBeenCalledWith("Unlocking issue #42 after agent workflow execution"); + expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully unlocked issue #42"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should skip unlocking if issue is not locked", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.get).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 42, + }); + + // Should not call unlock since issue is not locked + expect(mockGithub.rest.issues.unlock).not.toHaveBeenCalled(); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #42 is locked"); + expect(mockCore.info).toHaveBeenCalledWith("ℹ️ Issue #42 is not locked, skipping unlock operation"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fail when issue number is not found in context", async () => { + // Remove issue number from context + global.context.issue = {}; + delete global.context.payload.issue; + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.unlock).not.toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalledWith("Issue number not found in context"); + }); + + it("should handle API errors gracefully", async () => { + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: true, + }, + }); + + // Mock API error + const apiError = new Error("Issue was not locked"); + mockGithub.rest.issues.unlock.mockRejectedValue(apiError); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.unlock).toHaveBeenCalled(); + expect(mockCore.error).toHaveBeenCalledWith("Failed to unlock issue: Issue was not locked"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to unlock issue #42: Issue was not locked"); + }); + + it("should handle non-Error exceptions", async () => { + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: true, + }, + }); + + // Mock non-Error exception + mockGithub.rest.issues.unlock.mockRejectedValue("String error"); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockCore.error).toHaveBeenCalledWith("Failed to unlock issue: String error"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to unlock issue #42: String error"); + }); + + it("should work with different issue numbers", async () => { + // Change issue number + global.context.issue = { number: 200 }; + global.context.payload.issue = { number: 200 }; + + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 200, + locked: true, + }, + }); + + mockGithub.rest.issues.unlock.mockResolvedValue({ + status: 204, + }); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.unlock).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 200, + }); + + expect(mockCore.info).toHaveBeenCalledWith("Checking if issue #200 is locked"); + expect(mockCore.info).toHaveBeenCalledWith("Unlocking issue #200 after agent workflow execution"); + expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully unlocked issue #200"); + }); + + it("should handle permission errors", async () => { + // Mock issue get to return locked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: true, + }, + }); + + // Mock permission error + const permissionError = new Error("Resource not accessible by integration"); + mockGithub.rest.issues.unlock.mockRejectedValue(permissionError); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + expect(mockGithub.rest.issues.unlock).toHaveBeenCalled(); + expect(mockCore.error).toHaveBeenCalledWith("Failed to unlock issue: Resource not accessible by integration"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to unlock issue #42: Resource not accessible by integration"); + }); + + it("should skip if issue is already unlocked (redundant test for completeness)", async () => { + // Mock issue get to return unlocked issue + mockGithub.rest.issues.get.mockResolvedValue({ + data: { + number: 42, + locked: false, + }, + }); + + // Execute the script + await eval(`(async () => { ${unlockIssueScript} })()`); + + // Should skip unlock since issue is not locked + expect(mockGithub.rest.issues.unlock).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith("ℹ️ Issue #42 is not locked, skipping unlock operation"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/pkg/workflow/lock_for_agent_test.go b/pkg/workflow/lock_for_agent_test.go new file mode 100644 index 00000000000..debb2f4b405 --- /dev/null +++ b/pkg/workflow/lock_for_agent_test.go @@ -0,0 +1,252 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/testutil" +) + +func TestLockForAgentWorkflow(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "lock-for-agent-test") + + // Create a test markdown file with lock-for-agent enabled + testContent := `--- +on: + issues: + types: [opened] + lock-for-agent: true + reaction: eyes +engine: copilot +safe-outputs: + add-comment: {} +--- + +# Lock For Agent Test + +Test workflow with lock-for-agent enabled. +` + + testFile := filepath.Join(tmpDir, "test-lock-for-agent.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Verify lock-for-agent field is parsed correctly + if !workflowData.LockForAgent { + t.Error("Expected LockForAgent to be true") + } + + // Generate YAML and verify it contains lock/unlock steps + yamlContent, err := compiler.generateYAML(workflowData, testFile) + if err != nil { + t.Fatalf("Failed to generate YAML: %v", err) + } + + // Check for lock-specific content in generated YAML + expectedStrings := []string{ + "Lock issue for agent workflow", + "Unlock issue after agent workflow", + "GH_AW_LOCK_FOR_AGENT: \"true\"", + "lockForAgent && eventName === \"issues\"", + "This issue has been locked while the workflow is running", + } + + for _, expected := range expectedStrings { + if !strings.Contains(yamlContent, expected) { + t.Errorf("Generated YAML does not contain expected string: %s", expected) + } + } + + // Verify lock step is in activation job + activationJobSection := extractJobSection(yamlContent, "activation") + if !strings.Contains(activationJobSection, "Lock issue for agent workflow") { + t.Error("Activation job should contain the lock step") + } + + // Verify unlock step is in conclusion job + conclusionJobSection := extractJobSection(yamlContent, "conclusion") + if !strings.Contains(conclusionJobSection, "Unlock issue after agent workflow") { + t.Error("Conclusion job should contain the unlock step") + } + + // Verify unlock step has always() condition + if !strings.Contains(conclusionJobSection, "if: (always())") { + t.Error("Unlock step should have always() condition") + } +} + +func TestLockForAgentWithoutReaction(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "lock-for-agent-no-reaction-test") + + // Create a test markdown file with lock-for-agent but no reaction + testContent := `--- +on: + issues: + types: [opened] + lock-for-agent: true +engine: copilot +safe-outputs: + add-comment: {} +--- + +# Lock For Agent Test Without Reaction + +Test workflow with lock-for-agent but no reaction. +` + + testFile := filepath.Join(tmpDir, "test-lock-no-reaction.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Verify lock-for-agent field is parsed correctly + if !workflowData.LockForAgent { + t.Error("Expected LockForAgent to be true") + } + + // Generate YAML and verify it contains lock/unlock steps + yamlContent, err := compiler.generateYAML(workflowData, testFile) + if err != nil { + t.Fatalf("Failed to generate YAML: %v", err) + } + + // Lock and unlock steps should still be present + if !strings.Contains(yamlContent, "Lock issue for agent workflow") { + t.Error("Generated YAML should contain lock step even without reaction") + } + + if !strings.Contains(yamlContent, "Unlock issue after agent workflow") { + t.Error("Generated YAML should contain unlock step even without reaction") + } + + // The GH_AW_LOCK_FOR_AGENT env var should not be set (no reaction step to set it) + if strings.Contains(yamlContent, "GH_AW_LOCK_FOR_AGENT: \"true\"") { + t.Error("Generated YAML should not set GH_AW_LOCK_FOR_AGENT env var without reaction step") + } +} + +func TestLockForAgentDisabled(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "lock-for-agent-disabled-test") + + // Create a test markdown file without lock-for-agent + testContent := `--- +on: + issues: + types: [opened] + reaction: eyes +engine: copilot +safe-outputs: + add-comment: {} +--- + +# Test Without Lock For Agent + +Test workflow without lock-for-agent. +` + + testFile := filepath.Join(tmpDir, "test-no-lock.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Verify lock-for-agent field is false by default + if workflowData.LockForAgent { + t.Error("Expected LockForAgent to be false by default") + } + + // Generate YAML and verify it does not contain lock/unlock steps + yamlContent, err := compiler.generateYAML(workflowData, testFile) + if err != nil { + t.Fatalf("Failed to generate YAML: %v", err) + } + + // Lock and unlock steps should not be present + if strings.Contains(yamlContent, "Lock issue for agent workflow") { + t.Error("Generated YAML should not contain lock step when lock-for-agent is disabled") + } + + if strings.Contains(yamlContent, "Unlock issue after agent workflow") { + t.Error("Generated YAML should not contain unlock step when lock-for-agent is disabled") + } + + // The JavaScript code checking for GH_AW_LOCK_FOR_AGENT will still be in the script, + // but the environment variable itself should not be set + if strings.Contains(yamlContent, "GH_AW_LOCK_FOR_AGENT: \"true\"") { + t.Error("Generated YAML should not set GH_AW_LOCK_FOR_AGENT env var when lock-for-agent is disabled") + } +} + +func TestLockForAgentOnPullRequest(t *testing.T) { + // Create temporary directory for test files + tmpDir := testutil.TempDir(t, "lock-for-agent-pr-test") + + // Create a test markdown file with pull_request event (should not cause errors) + testContent := `--- +on: + pull_request: + types: [opened] + reaction: eyes +engine: copilot +safe-outputs: + add-comment: {} +--- + +# Test Lock For Agent with PR + +Test that lock-for-agent on issues doesn't break PR workflows. +` + + testFile := filepath.Join(tmpDir, "test-pr.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Generate YAML - should succeed without errors + yamlContent, err := compiler.generateYAML(workflowData, testFile) + if err != nil { + t.Fatalf("Failed to generate YAML: %v", err) + } + + // Lock steps should not be present for PR event (no lock-for-agent in on.pull_request) + if strings.Contains(yamlContent, "Lock issue for agent workflow") { + t.Error("Generated YAML should not contain lock step for pull_request event") + } +} diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 0c8a9fd0076..20d31d83e91 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -169,6 +169,35 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa }) steps = append(steps, scriptSteps...) + // Add unlock step if lock-for-agent is enabled + if data.LockForAgent { + // Build condition: only unlock if issue was locked by activation job + // Use the issue_locked output from activation job to determine if unlock is needed + issueNumberCheck := BuildPropertyAccess("github.event.issue.number") + lockedOutputCheck := BuildEquals( + BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.issue_locked", constants.ActivationJobName)), + BuildStringLiteral("true"), + ) + + unlockCondition := buildAnd( + BuildFunctionCall("always"), // Always run, even on failure + buildAnd(issueNumberCheck, lockedOutputCheck), + ) + + steps = append(steps, " - name: Unlock issue after agent workflow\n") + steps = append(steps, " id: unlock-issue\n") + steps = append(steps, fmt.Sprintf(" if: %s\n", unlockCondition.Render())) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add the unlock-issue script + formattedScript := FormatJavaScriptForYAML(unlockIssueScript) + steps = append(steps, formattedScript...) + + notifyCommentLog.Print("Added unlock issue step to conclusion job") + } + // Add GitHub App token invalidation step if app is configured if data.SafeOutputs.App != nil { notifyCommentLog.Print("Adding GitHub App token invalidation step to conclusion job")