mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Adds a 'Create PR' button to the button bar after attempt_completion
This commit is contained in:
parent
8e4b145681
commit
66b0377087
10 changed files with 275 additions and 18 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -562,6 +562,7 @@ describe("ClineProvider", () => {
|
|||
taskSyncEnabled: false,
|
||||
featureRoomoteControlEnabled: false,
|
||||
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
||||
isGitRepository: false,
|
||||
}
|
||||
|
||||
const message: ExtensionMessage = {
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ export type ExtensionState = Pick<
|
|||
remoteControlEnabled: boolean
|
||||
taskSyncEnabled: boolean
|
||||
featureRoomoteControlEnabled: boolean
|
||||
isGitRepository: boolean
|
||||
}
|
||||
|
||||
export interface ClineSayTool {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
openRouterImageGenerationSelectedModel: "",
|
||||
includeCurrentTime: true,
|
||||
includeCurrentCost: true,
|
||||
isGitRepository: false,
|
||||
})
|
||||
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue