diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift index d9a06a4..f626622 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift @@ -50,10 +50,64 @@ public actor ClaudeLauncher { lines.append("## Instructions") lines.append("1. Study the ticket thoroughly — use dangerouslyDisableSandbox: true for ALL gh/glab commands") lines.append("2. Create an implementation plan") + lines.append("3. Implement the plan") + lines.append("4. Commit the changes with a descriptive message") + lines.append("5. Push the branch to origin") + + let ticketIsPR = ticketURL.map(Self.isPullRequestURL) ?? false + if ticketIsPR { + lines.append("6. The ticket is itself a pull/merge request — pushing the branch updates it; do not open a new one") + } else { + appendOpenPRStep( + to: &lines, + provider: provider, + ticketNumber: session.ticketNumber, + hasTicket: ticketURL != nil + ) + } return lines.joined(separator: "\n") } + /// Append the final "open a PR/MR" instruction, branching on provider. + private func appendOpenPRStep( + to lines: inout [String], + provider: Provider?, + ticketNumber: Int?, + hasTicket: Bool + ) { + let suffix = hasTicket ? " linked to the ticket" : "" + switch provider { + case .github: + lines.append("6. Open a pull request\(suffix):") + lines.append("") + lines.append("```bash") + if let n = ticketNumber { + lines.append("gh pr create --title \"\" --body \"Closes #\(n)\" --base main") + } else { + lines.append("gh pr create --fill --base main") + } + lines.append("```") + case .gitlab: + lines.append("6. Open a merge request\(suffix):") + lines.append("") + lines.append("```bash") + if let n = ticketNumber { + lines.append("glab mr create --title \"\" --description \"Closes #\(n)\" --target-branch main") + } else { + lines.append("glab mr create --fill --target-branch main") + } + lines.append("```") + case nil: + lines.append("6. Open a pull request\(suffix)") + } + } + + /// True when the URL points at a pull/merge request rather than an issue. + private static func isPullRequestURL(_ url: String) -> Bool { + url.contains("/pull/") || url.contains("/merge_requests/") + } + /// Write prompt to temp file and return the launch command. public func launchCommand(sessionID: UUID, worktreePath: String, prompt: String) throws -> String { let tmpDir = FileManager.default.temporaryDirectory diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift index 6043306..418fec5 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift @@ -7,7 +7,7 @@ import Testing @Test func generatePromptWithGitHubProvider() async { let launcher = ClaudeLauncher() - let session = Session(name: "test-session") + let session = Session(name: "test-session", ticketNumber: 42) let worktree = SessionWorktree( sessionID: session.id, repoName: "my-repo", @@ -28,11 +28,17 @@ import Testing #expect(prompt.contains("feature/42-cool")) #expect(prompt.contains("gh issue view")) #expect(prompt.contains("dangerouslyDisableSandbox")) + // Completion instructions: commit / push / open PR with ticket linkage + #expect(prompt.contains("Commit the changes")) + #expect(prompt.contains("Push the branch")) + #expect(prompt.contains("gh pr create")) + #expect(prompt.contains("Closes #42")) + #expect(!prompt.contains("glab mr create")) } @Test func generatePromptWithGitLabProvider() async { let launcher = ClaudeLauncher() - let session = Session(name: "test-session") + let session = Session(name: "test-session", ticketNumber: 10) let prompt = await launcher.generatePrompt( session: session, @@ -43,6 +49,10 @@ import Testing #expect(prompt.contains("glab issue view")) #expect(prompt.contains("dangerouslyDisableSandbox")) + #expect(prompt.contains("glab mr create")) + #expect(prompt.contains("Closes #10")) + #expect(prompt.contains("merge request")) + #expect(!prompt.contains("gh pr create")) } @Test func generatePromptWithNilProvider() async { @@ -59,6 +69,10 @@ import Testing #expect(prompt.contains("URL: https://example.com/ticket/1")) #expect(!prompt.contains("gh issue")) #expect(!prompt.contains("glab issue")) + // With an unknown provider, we must not emit a provider-specific CLI command + #expect(!prompt.contains("gh pr create")) + #expect(!prompt.contains("glab mr create")) + #expect(prompt.contains("Open a pull request")) } @Test func generatePromptWithNoTicket() async { @@ -82,6 +96,10 @@ import Testing #expect(prompt.hasPrefix("/plan")) #expect(prompt.contains("| repo |")) #expect(!prompt.contains("## Ticket")) + // Without a ticket, the PR step is still present (generic form) + #expect(prompt.contains("Push the branch")) + #expect(prompt.contains("Open a pull request")) + #expect(!prompt.contains("Closes #")) } @Test func generatePromptWithEmptyWorktrees() async { @@ -99,6 +117,40 @@ import Testing #expect(prompt.contains("## Instructions")) } +@Test func generatePromptWhenTicketIsPullRequestGitHub() async { + let launcher = ClaudeLauncher() + let session = Session(name: "test-session", ticketNumber: 77) + + let prompt = await launcher.generatePrompt( + session: session, + worktrees: [], + ticketURL: "https://github.com/org/repo/pull/77", + provider: .github + ) + + // When the ticket is already a PR, we must not instruct the agent to open a new one + #expect(!prompt.contains("gh pr create")) + #expect(!prompt.contains("Closes #")) + #expect(prompt.contains("Push the branch")) + #expect(prompt.contains("pushing the branch updates it")) +} + +@Test func generatePromptWhenTicketIsMergeRequestGitLab() async { + let launcher = ClaudeLauncher() + let session = Session(name: "test-session", ticketNumber: 77) + + let prompt = await launcher.generatePrompt( + session: session, + worktrees: [], + ticketURL: "https://gitlab.com/org/repo/-/merge_requests/77", + provider: .gitlab + ) + + #expect(!prompt.contains("glab mr create")) + #expect(!prompt.contains("Closes #")) + #expect(prompt.contains("pushing the branch updates it")) +} + // MARK: - launchCommand() @Test func launchCommandWritesTempFile() async throws { diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index f6d1c7a..d31a8cb 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -323,8 +323,18 @@ gh issue view https://github.com/org/repo/issues/123 --comments ## Instructions 1. Study the ticket thoroughly — use dangerouslyDisableSandbox: true for ALL gh/glab commands 2. Create an implementation plan +3. Implement the plan +4. Commit the changes with a descriptive message +5. Push the branch to origin +6. Open a pull request linked to the ticket: + +```bash +gh pr create --title "" --body "Closes #123" --base main +``` ~~~ +For GitLab tickets, substitute `glab mr create --title "" --description "Closes #{number}" --target-branch main` on step 6 (use "merge request" instead of "pull request"). When no ticket number is available, drop the body/description and fall back to `gh pr create --fill` / `glab mr create --fill`. + **When an existing PR was detected**, add this section to the prompt between `## Ticket` and `## Instructions`: ~~~markdown @@ -346,9 +356,12 @@ And update the Instructions section to: 1. Review the existing PR and its changes — use dangerouslyDisableSandbox: true for ALL gh/glab commands 2. Study the ticket thoroughly 3. Create an implementation plan that builds on the existing work +4. Implement the plan +5. Commit the changes with a descriptive message +6. Push the branch — this updates the existing PR automatically; do NOT open a new one ~~~ -For MyGitLab, add: `4. If any changes to my-project are required, create a new worktree with a feature branch before making modifications` +For MyGitLab, add: `7. If any changes to my-project are required, create a new worktree with a feature branch before making modifications` ### CLI Commands for Fetching Issues