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"
|
schemars = "0.8"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
notify-rust = "4.11"
|
notify-rust = "4.11"
|
||||||
|
octocrab = "0.44"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
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::Config::decl(),
|
||||||
vibe_kanban::models::config::ThemeMode::decl(),
|
vibe_kanban::models::config::ThemeMode::decl(),
|
||||||
vibe_kanban::models::config::EditorConfig::decl(),
|
vibe_kanban::models::config::EditorConfig::decl(),
|
||||||
|
vibe_kanban::models::config::GitHubConfig::decl(),
|
||||||
vibe_kanban::models::config::EditorType::decl(),
|
vibe_kanban::models::config::EditorType::decl(),
|
||||||
vibe_kanban::models::config::EditorConstants::decl(),
|
vibe_kanban::models::config::EditorConstants::decl(),
|
||||||
vibe_kanban::models::config::SoundFile::decl(),
|
vibe_kanban::models::config::SoundFile::decl(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct Config {
|
|||||||
pub sound_file: SoundFile,
|
pub sound_file: SoundFile,
|
||||||
pub push_notifications: bool,
|
pub push_notifications: bool,
|
||||||
pub editor: EditorConfig,
|
pub editor: EditorConfig,
|
||||||
|
pub github: GitHubConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
@@ -39,6 +40,13 @@ pub struct EditorConfig {
|
|||||||
pub custom_command: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -150,6 +158,7 @@ impl Default for Config {
|
|||||||
sound_file: SoundFile::AbstractSound4,
|
sound_file: SoundFile::AbstractSound4,
|
||||||
push_notifications: true,
|
push_notifications: true,
|
||||||
editor: EditorConfig::default(),
|
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 {
|
impl EditorConfig {
|
||||||
pub fn get_command(&self) -> Vec<String> {
|
pub fn get_command(&self) -> Vec<String> {
|
||||||
match &self.editor_type {
|
match &self.editor_type {
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ pub struct UpdateTaskAttempt {
|
|||||||
// Currently no updateable fields, but keeping struct for API compatibility
|
// 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)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateFollowUpAttempt {
|
pub struct CreateFollowUpAttempt {
|
||||||
@@ -403,9 +414,9 @@ impl TaskAttempt {
|
|||||||
FROM task_attempts ta
|
FROM task_attempts ta
|
||||||
JOIN tasks t ON ta.task_id = t.id
|
JOIN tasks t ON ta.task_id = t.id
|
||||||
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#,
|
WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#,
|
||||||
attempt_id,
|
attempt_id,
|
||||||
task_id,
|
task_id,
|
||||||
project_id
|
project_id
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -1523,4 +1534,207 @@ impl TaskAttempt {
|
|||||||
|
|
||||||
Ok(commit_id.to_string())
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
|
config::Config,
|
||||||
execution_process::{ExecutionProcess, ExecutionProcessSummary},
|
execution_process::{ExecutionProcess, ExecutionProcessSummary},
|
||||||
task::Task,
|
task::Task,
|
||||||
task_attempt::{
|
task_attempt::{
|
||||||
BranchStatus, CreateFollowUpAttempt, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus,
|
BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt,
|
||||||
WorktreeDiff,
|
TaskAttemptStatus, WorktreeDiff,
|
||||||
},
|
},
|
||||||
task_attempt_activity::{
|
task_attempt_activity::{
|
||||||
CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt,
|
CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt,
|
||||||
@@ -30,6 +31,13 @@ pub struct RebaseTaskAttemptRequest {
|
|||||||
pub new_base_branch: Option<String>,
|
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(
|
pub async fn get_task_attempts(
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<SqlitePool>,
|
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)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct OpenEditorRequest {
|
pub struct OpenEditorRequest {
|
||||||
editor_type: Option<String>,
|
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",
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/delete-file",
|
||||||
post(delete_task_attempt_file),
|
post(delete_task_attempt_file),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/create-pr",
|
||||||
|
post(create_github_pr),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/execution-processes",
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/execution-processes",
|
||||||
get(get_task_attempt_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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
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 type { ThemeMode, EditorType, SoundFile } from 'shared/types';
|
||||||
import {
|
import {
|
||||||
EXECUTOR_TYPES,
|
EXECUTOR_TYPES,
|
||||||
@@ -271,6 +271,71 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notifications</CardTitle>
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
GitPullRequest,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { makeRequest } from '@/lib/api';
|
import { makeRequest } from '@/lib/api';
|
||||||
import type {
|
import type {
|
||||||
@@ -58,8 +62,38 @@ export function TaskAttemptComparePage() {
|
|||||||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
|
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
|
||||||
const [showUncommittedWarning, setShowUncommittedWarning] = useState(false);
|
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
|
// 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 () => {
|
const fetchDiff = useCallback(async () => {
|
||||||
if (!projectId || !taskId || !attemptId) return;
|
if (!projectId || !taskId || !attemptId) return;
|
||||||
|
|
||||||
@@ -114,10 +148,18 @@ export function TaskAttemptComparePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId && taskId && attemptId) {
|
if (projectId && taskId && attemptId) {
|
||||||
|
fetchTaskDetails();
|
||||||
fetchDiff();
|
fetchDiff();
|
||||||
fetchBranchStatus();
|
fetchBranchStatus();
|
||||||
}
|
}
|
||||||
}, [projectId, taskId, attemptId, fetchDiff, fetchBranchStatus]);
|
}, [
|
||||||
|
projectId,
|
||||||
|
taskId,
|
||||||
|
attemptId,
|
||||||
|
fetchTaskDetails,
|
||||||
|
fetchDiff,
|
||||||
|
fetchBranchStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
navigate(`/projects/${projectId}/tasks/${taskId}`);
|
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 getChunkClassName = (chunkType: DiffChunkType) => {
|
||||||
const baseClass = 'font-mono text-sm whitespace-pre py-1 flex';
|
const baseClass = 'font-mono text-sm whitespace-pre py-1 flex';
|
||||||
|
|
||||||
@@ -547,18 +656,34 @@ export function TaskAttemptComparePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!branchStatus?.merged && (
|
{!branchStatus?.merged && (
|
||||||
<Button
|
<>
|
||||||
onClick={handleMergeClick}
|
<Button
|
||||||
disabled={
|
onClick={handleCreatePRClick}
|
||||||
merging ||
|
disabled={
|
||||||
!diff ||
|
creatingPR ||
|
||||||
diff.files.length === 0 ||
|
!diff ||
|
||||||
Boolean(branchStatus?.is_behind)
|
diff.files.length === 0 ||
|
||||||
}
|
Boolean(branchStatus?.is_behind)
|
||||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400"
|
}
|
||||||
>
|
variant="outline"
|
||||||
{merging ? 'Merging...' : 'Merge Changes'}
|
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -788,6 +913,63 @@ export function TaskAttemptComparePage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
|
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 ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";
|
||||||
|
|
||||||
export type EditorConfig = { editor_type: EditorType, custom_command: string | null, };
|
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 EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom";
|
||||||
|
|
||||||
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };
|
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };
|
||||||
|
|||||||
Reference in New Issue
Block a user