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:
committed by
GitHub
parent
7fb28b3f38
commit
6a8d7d8a19
@@ -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"] }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>, };
|
||||
|
||||
Reference in New Issue
Block a user