Adds a 'Create PR' button to the button bar after attempt_completion

This commit is contained in:
Bruno Bergher 2025-11-04 14:13:56 +00:00
parent 8e4b145681
commit 66b0377087
10 changed files with 275 additions and 18 deletions

View file

@ -1886,6 +1886,12 @@ export class ClineProvider
const currentMode = mode ?? defaultModeSlug
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
// Check if the current workspace is a git repository
const gitInfo = await getWorkspaceGitInfo()
// A repository is valid if we found ANY git info (not just a remote URL)
// This includes defaultBranch, which is populated even for worktrees.
const isGitRepository = Object.keys(gitInfo).length > 0
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@ -2016,6 +2022,7 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
isGitRepository,
}
}
@ -2250,6 +2257,7 @@ export class ClineProvider
return false
}
})(),
isGitRepository: false, // Will be computed in getStateToPostToWebview
}
}

View file

@ -562,6 +562,7 @@ describe("ClineProvider", () => {
taskSyncEnabled: false,
featureRoomoteControlEnabled: false,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
isGitRepository: false,
}
const message: ExtensionMessage = {

View file

@ -284,6 +284,62 @@ Please analyze this codebase and create an AGENTS.md file containing:
Remember: The goal is to create documentation that enables AI assistants to be immediately productive in this codebase, focusing on project-specific knowledge that isn't obvious from the code structure alone.`,
},
create_pr: {
name: "create-pr",
description: "Create a GitHub pull request from current branch",
content: `<task>
Stage and commit any outstanding changes, then review the changes made in this branch versus main/master and create a pull request using the gh CLI.
</task>
<instructions>
1. Check if there are any unstaged/uncommitted changes:
- Run: git status
- If changes exist, stage and commit them with a descriptive message
2. Identify the base branch (main or master):
- Check which exists: git branch --list main master
- Use the one that exists as base branch
3. Get current branch name:
- Run: git branch --show-current
4. Analyze all changes between current branch and base:
- Run: git diff <base-branch>...HEAD
- Also get commit messages: git log <base-branch>..HEAD --oneline
5. Generate PR title and description:
- Analyze the diff and commit messages
- Create concise, descriptive title (50 chars max)
- Write clear PR description explaining:
* What changed and why
* Key implementation details
* Any breaking changes or important notes
6. Get repository info:
- Extract from: git remote get-url origin
- Parse to get org/repo format
7. Check if gh CLI is installed:
- Run: gh --version
- If not found, guide user to install:
* macOS: brew install gh
* Windows: winget install GitHub.cli
* Linux: See https://github.com/cli/cli#installation
* After install: gh auth login
8. Create the pull request:
- Run: gh pr create --repo <org/repo> --head <current-branch> --title "<generated-title>" --body "<generated-description>"
9. After successful PR creation:
- Extract PR URL from gh output
- Present success message with:
* Link to the created PR
* Offer to have it reviewed by Roo Code Cloud's PR Reviewer agent
* Link: https://roocode.com/reviewer
If gh CLI is not installed or authenticated, provide clear setup instructions and wait for user to complete setup before proceeding.
</instructions>`,
},
}
/**

View file

@ -362,6 +362,7 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
isGitRepository: boolean
}
export interface ClineSayTool {

View file

@ -33,6 +33,7 @@ vitest.mock("fs", () => ({
promises: {
access: vitest.fn(),
readFile: vitest.fn(),
stat: vitest.fn(),
},
}))
@ -470,6 +471,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Mock git config file content
const mockConfig = `
[core]
@ -524,6 +531,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Mock git config file without URL
const mockConfig = `
[core]
@ -561,6 +574,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Setup the readFile mock to return different values based on the path
gitSpy.mockImplementation((path: any, encoding: any) => {
if (path === configPath) {
@ -588,6 +607,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Setup the readFile mock to return different values based on the path
gitSpy.mockImplementation((path: any, encoding: any) => {
if (path === configPath) {
@ -619,6 +644,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Mock git config file with SSH URL
const mockConfig = `
[core]
@ -654,6 +685,99 @@ describe("getGitRepositoryInfo", () => {
defaultBranch: "main",
})
})
it("should handle git worktrees where .git is a file", async () => {
// Clear previous mocks
vitest.clearAllMocks()
// Create a spy to track the implementation
const accessSpy = vitest.spyOn(fs.promises, "access")
const statSpy = vitest.spyOn(fs.promises, "stat")
const readFileSpy = vitest.spyOn(fs.promises, "readFile")
// Mock successful access to .git file (not directory)
accessSpy.mockResolvedValue(undefined)
// Mock stat to indicate .git is a file (worktree)
statSpy.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any)
// Mock .git file content (worktree reference)
const gitFileContent = "gitdir: /path/to/main/repo/.git/worktrees/my-worktree"
// Mock git config file content from the actual git directory
const mockConfig = `
[core]
repositoryformatversion = 0
filemode = true
bare = false
[remote "origin"]
url = https://github.com/RooCodeInc/Roo-Code.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/main
`
// Mock HEAD file content
const mockHead = "ref: refs/heads/feature-branch"
// Setup the readFile mock to return different values based on the path
readFileSpy.mockImplementation((filePath: any, encoding: any) => {
const pathStr = String(filePath)
if (pathStr.endsWith(".git")) {
// Reading the .git file itself
return Promise.resolve(gitFileContent)
} else if (pathStr.includes("config")) {
return Promise.resolve(mockConfig)
} else if (pathStr.includes("HEAD")) {
return Promise.resolve(mockHead)
}
return Promise.reject(new Error(`Unexpected path: ${pathStr}`))
})
const result = await getGitRepositoryInfo(workspaceRoot)
// Verify that the worktree was handled correctly
expect(result).toEqual({
repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
repositoryName: "RooCodeInc/Roo-Code",
defaultBranch: "main",
})
// Verify the .git file was read
expect(statSpy).toHaveBeenCalledWith(gitDir)
expect(readFileSpy).toHaveBeenCalledWith(gitDir, "utf8")
})
it("should return empty object if .git file has invalid format", async () => {
// Clear previous mocks
vitest.clearAllMocks()
// Create a spy to track the implementation
const accessSpy = vitest.spyOn(fs.promises, "access")
const statSpy = vitest.spyOn(fs.promises, "stat")
const readFileSpy = vitest.spyOn(fs.promises, "readFile")
// Mock successful access to .git file
accessSpy.mockResolvedValue(undefined)
// Mock stat to indicate .git is a file (worktree)
statSpy.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any)
// Mock invalid .git file content
const gitFileContent = "invalid content without gitdir"
readFileSpy.mockResolvedValue(gitFileContent)
const result = await getGitRepositoryInfo(workspaceRoot)
expect(result).toEqual({})
})
})
describe("convertGitUrlToHttps", () => {
@ -804,6 +928,12 @@ describe("getWorkspaceGitInfo", () => {
// Mock successful access to .git directory
gitSpy.mockResolvedValue(undefined)
// Mock stat to indicate .git is a directory (not a worktree file)
vitest.mocked(fs.promises.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
// Mock git config file content
const mockConfig = `
[remote "origin"]

View file

@ -29,9 +29,9 @@ export interface GitCommit {
*/
export async function getGitRepositoryInfo(workspaceRoot: string): Promise<GitRepositoryInfo> {
try {
const gitDir = path.join(workspaceRoot, ".git")
let gitDir = path.join(workspaceRoot, ".git")
// Check if .git directory exists
// Check if .git exists (could be a directory or file)
try {
await fs.access(gitDir)
} catch {
@ -39,6 +39,31 @@ export async function getGitRepositoryInfo(workspaceRoot: string): Promise<GitRe
return {}
}
// Check if .git is a file (worktree) or directory
const stats = await fs.stat(gitDir)
if (stats.isFile()) {
// This is a worktree - read the .git file to get the actual git directory
const gitFileContent = await fs.readFile(gitDir, "utf8")
const gitdirMatch = gitFileContent.match(/gitdir:\s*(.+)/)
if (gitdirMatch && gitdirMatch[1]) {
const worktreeGitDir = gitdirMatch[1].trim() // This is the worktree's .git directory
gitDir = worktreeGitDir // Update gitDir to the actual .git directory
// For worktrees, the config is in the main repo's .git directory
// Worktree path is like: /path/to/repo/.git/worktrees/name
// Main config is at: /path/to/repo/.git/config
if (worktreeGitDir.includes("/worktrees/")) {
// Extract the path to the main repository's .git directory
const mainGitDir = worktreeGitDir.split("/worktrees/")[0]
// Use this mainGitDir for reading the config file
gitDir = mainGitDir // This is crucial: we need to read config from the main repo's .git dir
}
} else {
// Invalid .git file format
return {}
}
}
const gitInfo: GitRepositoryInfo = {}
// Try to read git config file

View file

@ -124,6 +124,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
soundVolume,
cloudIsAuthenticated,
messageQueue = [],
isGitRepository = false,
} = useExtensionState()
const messagesRef = useRef(messages)
@ -207,6 +208,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const userRespondedRef = useRef<boolean>(false)
const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
const [prCreationRequested, setPrCreationRequested] = useState<boolean>(false)
const clineAskRef = useRef(clineAsk)
useEffect(() => {
@ -403,8 +405,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
// Show "Create PR" only if in a git repository and user hasn't already requested it
if (isGitRepository && !prCreationRequested) {
setPrimaryButtonText(t("chat:createPR.title"))
setSecondaryButtonText(t("chat:startNewTask.title"))
} else {
// If not in git repo or PR already created, only show "New Task"
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
}
break
case "resume_task":
setSendingDisabled(false)
@ -471,6 +481,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
everVisibleMessagesTsRef.current.clear() // Clear for new task
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
setIsCondensing(false) // Reset condensing state when switching tasks
setPrCreationRequested(false) // Reset PR flag for new task
// Note: sendingDisabled is not reset here as it's managed by message effects
// Clear any pending auto-approval timeout from previous task
@ -608,6 +619,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
text = text.trim()
if (text || images.length > 0) {
// Reset PR creation flag when user sends any message
// This allows creating another PR if more changes are made
setPrCreationRequested(false)
// Queue message if:
// - Task is busy (sendingDisabled)
// - API request in progress (isStreaming)
@ -723,8 +738,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
break
case "completion_result":
case "resume_completed_task":
// Waiting for feedback, but we can just present a new task button
startNewTask()
// Check if primary button is "Create PR"
if (primaryButtonText === t("chat:createPR.title")) {
// Mark that PR creation was requested
setPrCreationRequested(true)
// Send /create-pr command
handleSendMessage("/create-pr", [])
} else {
// Original behavior: start new task
startNewTask()
}
break
case "command_output":
vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" })
@ -735,7 +758,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk(undefined)
setEnableButtons(false)
},
[clineAsk, startNewTask],
[clineAsk, primaryButtonText, t, handleSendMessage, startNewTask],
)
const handleSecondaryButtonClick = useCallback(
@ -1928,17 +1951,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText === t("chat:createPR.title")
? t("chat:createPR.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t(
"chat:proceedWhileRunning.title",
)
? t(
"chat:proceedWhileRunning.tooltip",
)
: undefined
}>
<VSCodeButton
appearance="primary"

View file

@ -276,6 +276,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openRouterImageGenerationSelectedModel: "",
includeCurrentTime: true,
includeCurrentCost: true,
isGitRepository: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)

View file

@ -208,13 +208,14 @@ describe("mergeExtensionState", () => {
cloudIsAuthenticated: false,
sharingEnabled: false,
profileThresholds: {},
hasOpenedModeSelector: false, // Add the new required property
hasOpenedModeSelector: false,
maxImageFileSize: 5,
maxTotalImageSize: 20,
remoteControlEnabled: false,
taskSyncEnabled: false,
featureRoomoteControlEnabled: false,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
isGitRepository: false,
}
const prevState: ExtensionState = {

View file

@ -38,6 +38,10 @@
"title": "Start New Task",
"tooltip": "Begin a new task"
},
"createPR": {
"title": "Create PR",
"tooltip": "Create a pull request from your changes"
},
"proceedAnyways": {
"title": "Proceed Anyways",
"tooltip": "Continue while command executes"