Create PR from comparison screen (#41)

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Task attempt 0958c29b-aea3-42a4-9703-5fc5a6705b1c - Final changes

* Prettier

* Cargo fmt

* Clippy
This commit is contained in:
Louis Knight-Webb
2025-07-01 16:28:15 +01:00
committed by GitHub
parent 7fb28b3f38
commit 6a8d7d8a19
8 changed files with 592 additions and 20 deletions

View File

@@ -41,6 +41,7 @@ rmcp = { version = "0.1.5", features = ["server", "transport-io"] }
schemars = "0.8"
regex = "1.11.1"
notify-rust = "4.11"
octocrab = "0.44"
[build-dependencies]
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }

View File

@@ -76,6 +76,7 @@ fn main() {
vibe_kanban::models::config::Config::decl(),
vibe_kanban::models::config::ThemeMode::decl(),
vibe_kanban::models::config::EditorConfig::decl(),
vibe_kanban::models::config::GitHubConfig::decl(),
vibe_kanban::models::config::EditorType::decl(),
vibe_kanban::models::config::EditorConstants::decl(),
vibe_kanban::models::config::SoundFile::decl(),

View File

@@ -16,6 +16,7 @@ pub struct Config {
pub sound_file: SoundFile,
pub push_notifications: bool,
pub editor: EditorConfig,
pub github: GitHubConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -39,6 +40,13 @@ pub struct EditorConfig {
pub custom_command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct GitHubConfig {
pub token: Option<String>,
pub default_pr_base: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "lowercase")]
@@ -150,6 +158,7 @@ impl Default for Config {
sound_file: SoundFile::AbstractSound4,
push_notifications: true,
editor: EditorConfig::default(),
github: GitHubConfig::default(),
}
}
}
@@ -163,6 +172,15 @@ impl Default for EditorConfig {
}
}
impl Default for GitHubConfig {
fn default() -> Self {
Self {
token: None,
default_pr_base: Some("main".to_string()),
}
}
}
impl EditorConfig {
pub fn get_command(&self) -> Vec<String> {
match &self.editor_type {

View File

@@ -87,6 +87,17 @@ pub struct UpdateTaskAttempt {
// Currently no updateable fields, but keeping struct for API compatibility
}
/// GitHub PR creation parameters
pub struct CreatePrParams<'a> {
pub attempt_id: Uuid,
pub task_id: Uuid,
pub project_id: Uuid,
pub github_token: &'a str,
pub title: &'a str,
pub body: Option<&'a str>,
pub base_branch: Option<&'a str>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateFollowUpAttempt {
@@ -403,9 +414,9 @@ impl TaskAttempt {
FROM task_attempts ta
JOIN tasks t ON ta.task_id = t.id
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#,
attempt_id,
task_id,
project_id
attempt_id,
task_id,
project_id
)
.fetch_optional(pool)
.await?
@@ -1523,4 +1534,207 @@ impl TaskAttempt {
Ok(commit_id.to_string())
}
/// Create a GitHub PR for this task attempt
pub async fn create_github_pr(
pool: &SqlitePool,
params: CreatePrParams<'_>,
) -> Result<String, TaskAttemptError> {
// Get the task attempt with validation
let attempt = sqlx::query_as!(
TaskAttempt,
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
FROM task_attempts ta
JOIN tasks t ON ta.task_id = t.id
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#,
params.attempt_id,
params.task_id,
params.project_id
)
.fetch_optional(pool)
.await?
.ok_or(TaskAttemptError::TaskNotFound)?;
// Get the project to access the repository path
let project = Project::find_by_id(pool, params.project_id)
.await?
.ok_or(TaskAttemptError::ProjectNotFound)?;
// Extract GitHub repository information from the project path
let (owner, repo_name) = Self::extract_github_repo_info(&project.git_repo_path)?;
// Push the branch to GitHub first
Self::push_branch_to_github(&attempt.worktree_path, &attempt.branch, params.github_token)?;
// Create the PR using Octocrab
Self::create_pr_with_octocrab(
params.github_token,
&owner,
&repo_name,
&attempt.branch,
params.base_branch.unwrap_or("main"),
params.title,
params.body,
)
.await
}
/// Extract GitHub owner and repo name from git repo path
fn extract_github_repo_info(git_repo_path: &str) -> Result<(String, String), TaskAttemptError> {
// Try to extract from remote origin URL
let repo = Repository::open(git_repo_path)?;
let remote = repo.find_remote("origin").map_err(|_| {
TaskAttemptError::ValidationError("No 'origin' remote found".to_string())
})?;
let url = remote.url().ok_or_else(|| {
TaskAttemptError::ValidationError("Remote origin has no URL".to_string())
})?;
// Parse GitHub URL (supports both HTTPS and SSH formats)
let github_regex = regex::Regex::new(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?/?$")
.map_err(|e| TaskAttemptError::ValidationError(format!("Regex error: {}", e)))?;
if let Some(captures) = github_regex.captures(url) {
let owner = captures.get(1).unwrap().as_str().to_string();
let repo_name = captures.get(2).unwrap().as_str().to_string();
Ok((owner, repo_name))
} else {
Err(TaskAttemptError::ValidationError(format!(
"Not a GitHub repository: {}",
url
)))
}
}
/// Push the branch to GitHub remote
fn push_branch_to_github(
worktree_path: &str,
branch_name: &str,
github_token: &str,
) -> Result<(), TaskAttemptError> {
let repo = Repository::open(worktree_path)?;
// Get the remote
let remote = repo.find_remote("origin")?;
let remote_url = remote.url().ok_or_else(|| {
TaskAttemptError::ValidationError("Remote origin has no URL".to_string())
})?;
// Convert SSH URL to HTTPS URL if necessary
let https_url = if remote_url.starts_with("git@github.com:") {
// Convert git@github.com:owner/repo.git to https://github.com/owner/repo.git
remote_url.replace("git@github.com:", "https://github.com/")
} else if remote_url.starts_with("ssh://git@github.com/") {
// Convert ssh://git@github.com/owner/repo.git to https://github.com/owner/repo.git
remote_url.replace("ssh://git@github.com/", "https://github.com/")
} else {
remote_url.to_string()
};
// Create a temporary remote with HTTPS URL for pushing
let temp_remote_name = "temp_https_origin";
// Remove any existing temp remote
let _ = repo.remote_delete(temp_remote_name);
// Create temporary HTTPS remote
let mut temp_remote = repo.remote(temp_remote_name, &https_url)?;
// Create refspec for pushing the branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
// Set up authentication callback using the GitHub token
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::userpass_plaintext(username_from_url.unwrap_or("git"), github_token)
});
// Configure push options
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
// Push the branch
let push_result = temp_remote.push(&[&refspec], Some(&mut push_options));
// Clean up the temporary remote
let _ = repo.remote_delete(temp_remote_name);
// Check push result
push_result.map_err(TaskAttemptError::Git)?;
info!("Pushed branch {} to GitHub using HTTPS", branch_name);
Ok(())
}
/// Create a PR using Octocrab
async fn create_pr_with_octocrab(
github_token: &str,
owner: &str,
repo_name: &str,
head_branch: &str,
base_branch: &str,
title: &str,
body: Option<&str>,
) -> Result<String, TaskAttemptError> {
let octocrab = octocrab::OctocrabBuilder::new()
.personal_token(github_token.to_string())
.build()
.map_err(|e| {
TaskAttemptError::ValidationError(format!("Failed to create GitHub client: {}", e))
})?;
// Verify repository access
octocrab.repos(owner, repo_name).get().await.map_err(|e| {
TaskAttemptError::ValidationError(format!(
"Cannot access repository {}/{}: {}",
owner, repo_name, e
))
})?;
// Check if the base branch exists
octocrab
.repos(owner, repo_name)
.get_ref(&octocrab::params::repos::Reference::Branch(
base_branch.to_string(),
))
.await
.map_err(|e| {
TaskAttemptError::ValidationError(format!(
"Base branch '{}' does not exist: {}",
base_branch, e
))
})?;
// Check if the head branch exists
octocrab.repos(owner, repo_name)
.get_ref(&octocrab::params::repos::Reference::Branch(head_branch.to_string())).await
.map_err(|e| TaskAttemptError::ValidationError(format!("Head branch '{}' does not exist. Make sure the branch was pushed successfully: {}", head_branch, e)))?;
let pr = octocrab
.pulls(owner, repo_name)
.create(title, head_branch, base_branch)
.body(body.unwrap_or(""))
.send()
.await
.map_err(|e| match e {
octocrab::Error::GitHub { source, .. } => {
TaskAttemptError::ValidationError(format!(
"GitHub API error: {} (status: {})",
source.message,
source.status_code.as_u16()
))
}
_ => TaskAttemptError::ValidationError(format!("Failed to create PR: {}", e)),
})?;
info!(
"Created GitHub PR #{} for branch {}",
pr.number, head_branch
);
Ok(pr
.html_url
.map(|url| url.to_string())
.unwrap_or_else(|| "".to_string()))
}
}

View File

@@ -13,11 +13,12 @@ use tokio::sync::RwLock;
use uuid::Uuid;
use crate::models::{
config::Config,
execution_process::{ExecutionProcess, ExecutionProcessSummary},
task::Task,
task_attempt::{
BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus,
WorktreeDiff,
BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt,
TaskAttemptStatus, WorktreeDiff,
},
task_attempt_activity::{
CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt,
@@ -30,6 +31,13 @@ pub struct RebaseTaskAttemptRequest {
pub new_base_branch: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateGitHubPRRequest {
pub title: String,
pub body: Option<String>,
pub base_branch: Option<String>,
}
pub async fn get_task_attempts(
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
Extension(pool): Extension<SqlitePool>,
@@ -257,6 +265,83 @@ pub async fn merge_task_attempt(
}
}
pub async fn create_github_pr(
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
Extension(pool): Extension<SqlitePool>,
Json(request): Json<CreateGitHubPRRequest>,
) -> Result<ResponseJson<ApiResponse<String>>, StatusCode> {
// Verify task attempt exists and belongs to the correct task
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
Ok(false) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to check task attempt existence: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(true) => {}
}
// Load the user's GitHub configuration
let config = match Config::load(&crate::utils::config_path()) {
Ok(config) => config,
Err(e) => {
tracing::error!("Failed to load config: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let github_token = match config.github.token {
Some(token) => token,
None => {
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(
"GitHub token not configured. Please set your GitHub token in settings."
.to_string(),
),
}));
}
};
let base_branch = request
.base_branch
.or(config.github.default_pr_base)
.unwrap_or_else(|| "main".to_string());
match TaskAttempt::create_github_pr(
&pool,
CreatePrParams {
attempt_id,
task_id,
project_id,
github_token: &github_token,
title: &request.title,
body: request.body.as_deref(),
base_branch: Some(&base_branch),
},
)
.await
{
Ok(pr_url) => Ok(ResponseJson(ApiResponse {
success: true,
data: Some(pr_url),
message: Some("GitHub PR created successfully".to_string()),
})),
Err(e) => {
tracing::error!(
"Failed to create GitHub PR for attempt {}: {}",
attempt_id,
e
);
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to create PR: {}", e)),
}))
}
}
}
#[derive(serde::Deserialize)]
pub struct OpenEditorRequest {
editor_type: Option<String>,
@@ -900,6 +985,10 @@ pub fn task_attempts_router() -> Router {
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/delete-file",
post(delete_task_attempt_file),
)
.route(
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/create-pr",
post(create_github_pr),
)
.route(
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/execution-processes",
get(get_task_attempt_execution_processes),

View File

@@ -18,7 +18,7 @@ import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Loader2, Volume2 } from 'lucide-react';
import { Loader2, Volume2, Key } from 'lucide-react';
import type { ThemeMode, EditorType, SoundFile } from 'shared/types';
import {
EXECUTOR_TYPES,
@@ -271,6 +271,71 @@ export function Settings() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
GitHub Integration
</CardTitle>
<CardDescription>
Configure GitHub settings for creating pull requests from task
attempts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="github-token">Personal Access Token</Label>
<Input
id="github-token"
type="password"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
value={config.github.token || ''}
onChange={(e) =>
updateConfig({
github: {
...config.github,
token: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
GitHub Personal Access Token with 'repo' permissions. Required
for creating pull requests.{' '}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Create token here
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default-pr-base">Default PR Base Branch</Label>
<Input
id="default-pr-base"
placeholder="main"
value={config.github.default_pr_base || ''}
onChange={(e) =>
updateConfig({
github: {
...config.github,
default_pr_base: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
Default base branch for pull requests. Defaults to 'main' if
not specified.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>

View File

@@ -10,6 +10,9 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
ArrowLeft,
FileText,
@@ -20,6 +23,7 @@ import {
Trash2,
Eye,
EyeOff,
GitPullRequest,
} from 'lucide-react';
import { makeRequest } from '@/lib/api';
import type {
@@ -58,8 +62,38 @@ export function TaskAttemptComparePage() {
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [showUncommittedWarning, setShowUncommittedWarning] = useState(false);
const [creatingPR, setCreatingPR] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [prTitle, setPrTitle] = useState('');
const [prBody, setPrBody] = useState('');
const [prBaseBranch, setPrBaseBranch] = useState('main');
const [taskDetails, setTaskDetails] = useState<{
title: string;
description: string | null;
} | null>(null);
// Define callbacks first
const fetchTaskDetails = useCallback(async () => {
if (!projectId || !taskId) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`
);
if (response.ok) {
const result: ApiResponse<any> = await response.json();
if (result.success && result.data) {
setTaskDetails({
title: result.data.title,
description: result.data.description,
});
}
}
} catch (err) {
// Silently fail - not critical for the main functionality
}
}, [projectId, taskId]);
const fetchDiff = useCallback(async () => {
if (!projectId || !taskId || !attemptId) return;
@@ -114,10 +148,18 @@ export function TaskAttemptComparePage() {
useEffect(() => {
if (projectId && taskId && attemptId) {
fetchTaskDetails();
fetchDiff();
fetchBranchStatus();
}
}, [projectId, taskId, attemptId, fetchDiff, fetchBranchStatus]);
}, [
projectId,
taskId,
attemptId,
fetchTaskDetails,
fetchDiff,
fetchBranchStatus,
]);
const handleBackClick = () => {
navigate(`/projects/${projectId}/tasks/${taskId}`);
@@ -207,6 +249,73 @@ export function TaskAttemptComparePage() {
}
};
const handleCreatePRClick = async () => {
if (!projectId || !taskId || !attemptId) return;
// Auto-fill with task details if available
if (taskDetails) {
setPrTitle(`${taskDetails.title} (vibe-kanban)`);
setPrBody(taskDetails.description || '');
} else {
// Fallback if task details aren't available
setPrTitle('Task completion (vibe-kanban)');
setPrBody('');
}
setShowCreatePRDialog(true);
};
const handleConfirmCreatePR = async () => {
if (!projectId || !taskId || !attemptId) return;
try {
setCreatingPR(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/create-pr`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: prTitle,
body: prBody || null,
base_branch: prBaseBranch || null,
}),
}
);
if (response.ok) {
const result: ApiResponse<string> = await response.json();
if (result.success && result.data) {
// Open the PR URL in a new tab
window.open(result.data, '_blank');
setShowCreatePRDialog(false);
// Reset form
setPrTitle('');
setPrBody('');
setPrBaseBranch('main');
} else {
setError(result.message || 'Failed to create GitHub PR');
}
} else {
setError('Failed to create GitHub PR');
}
} catch (err) {
setError('Failed to create GitHub PR');
} finally {
setCreatingPR(false);
}
};
const handleCancelCreatePR = () => {
setShowCreatePRDialog(false);
// Reset form to empty state - will be auto-filled again when reopened
setPrTitle('');
setPrBody('');
setPrBaseBranch('main');
};
const getChunkClassName = (chunkType: DiffChunkType) => {
const baseClass = 'font-mono text-sm whitespace-pre py-1 flex';
@@ -547,18 +656,34 @@ export function TaskAttemptComparePage() {
</Button>
)}
{!branchStatus?.merged && (
<Button
onClick={handleMergeClick}
disabled={
merging ||
!diff ||
diff.files.length === 0 ||
Boolean(branchStatus?.is_behind)
}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400"
>
{merging ? 'Merging...' : 'Merge Changes'}
</Button>
<>
<Button
onClick={handleCreatePRClick}
disabled={
creatingPR ||
!diff ||
diff.files.length === 0 ||
Boolean(branchStatus?.is_behind)
}
variant="outline"
className="border-blue-300 text-blue-700 hover:bg-blue-50"
>
<GitPullRequest className="mr-2 h-4 w-4" />
{creatingPR ? 'Creating PR...' : 'Create PR'}
</Button>
<Button
onClick={handleMergeClick}
disabled={
merging ||
!diff ||
diff.files.length === 0 ||
Boolean(branchStatus?.is_behind)
}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400"
>
{merging ? 'Merging...' : 'Merge Changes'}
</Button>
</>
)}
</div>
</div>
@@ -788,6 +913,63 @@ export function TaskAttemptComparePage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create PR Dialog */}
<Dialog
open={showCreatePRDialog}
onOpenChange={() => handleCancelCreatePR()}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Create GitHub Pull Request</DialogTitle>
<DialogDescription>
Create a pull request for this task attempt on GitHub.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="pr-title">Title</Label>
<Input
id="pr-title"
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
placeholder="Enter PR title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-body">Description (optional)</Label>
<Textarea
id="pr-body"
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
placeholder="Enter PR description"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-base">Base Branch</Label>
<Input
id="pr-base"
value={prBaseBranch}
onChange={(e) => setPrBaseBranch(e.target.value)}
placeholder="main"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelCreatePR}>
Cancel
</Button>
<Button
onClick={handleConfirmCreatePR}
disabled={creatingPR || !prTitle.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
{creatingPR ? 'Creating...' : 'Create PR'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -4,12 +4,14 @@
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, };
export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, };
export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";
export type EditorConfig = { editor_type: EditorType, custom_command: string | null, };
export type GitHubConfig = { token: string | null, default_pr_base: string | null, };
export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom";
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };