feat: add GitHub PR list and context APIs
Add PRs list API with pagination Provide PR context API with optional diff Integrate PR picker in session UI
This commit is contained in:
@@ -54,6 +54,105 @@ pub struct GitHubPullRequestSummary {
|
|||||||
mergeable_state: Option<String>,
|
mergeable_state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestHeadRepo {
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
clone_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestContextResult {
|
||||||
|
connected: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<GitHubRepoRef>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pr: Option<GitHubPullRequestContext>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
issue_comments: Option<Vec<GitHubIssueComment>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
review_comments: Option<Vec<GitHubPullRequestReviewComment>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
files: Option<Vec<GitHubPullRequestFile>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
diff: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
checks: Option<GitHubChecksSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestsListResult {
|
||||||
|
connected: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<GitHubRepoRef>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
prs: Option<Vec<GitHubPullRequestContext>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
page: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
has_more: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestContext {
|
||||||
|
#[serde(flatten)]
|
||||||
|
summary: GitHubPullRequestSummary,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author: Option<GitHubUserSummary>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
head_label: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
head_repo: Option<GitHubPullRequestHeadRepo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
created_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestFile {
|
||||||
|
filename: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
status: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
additions: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
deletions: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
changes: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
patch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubPullRequestReviewComment {
|
||||||
|
id: u64,
|
||||||
|
url: String,
|
||||||
|
body: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author: Option<GitHubUserSummary>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
line: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
position: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
created_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GitHubPullRequestStatus {
|
pub struct GitHubPullRequestStatus {
|
||||||
@@ -142,6 +241,10 @@ pub struct GitHubIssuesListResult {
|
|||||||
repo: Option<GitHubRepoRef>,
|
repo: Option<GitHubRepoRef>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
issues: Option<Vec<GitHubIssueSummary>>,
|
issues: Option<Vec<GitHubIssueSummary>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
page: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
has_more: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -351,6 +454,65 @@ struct IssueCommentResponse {
|
|||||||
updated_at: Option<String>,
|
updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PullListItem {
|
||||||
|
number: u64,
|
||||||
|
title: String,
|
||||||
|
html_url: String,
|
||||||
|
state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
merged_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Option<IssueUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
base: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
head: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
mergeable: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
mergeable_state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PullFileResponse {
|
||||||
|
filename: String,
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
additions: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
deletions: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
changes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
patch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PullReviewCommentResponse {
|
||||||
|
id: u64,
|
||||||
|
html_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Option<IssueUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
line: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
position: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PrListItem {
|
struct PrListItem {
|
||||||
number: u64,
|
number: u64,
|
||||||
@@ -1378,6 +1540,7 @@ pub async fn github_pr_ready(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn github_issues_list(
|
pub async fn github_issues_list(
|
||||||
directory: String,
|
directory: String,
|
||||||
|
page: Option<u32>,
|
||||||
_state: State<'_, DesktopRuntime>,
|
_state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<GitHubIssuesListResult, String> {
|
) -> Result<GitHubIssuesListResult, String> {
|
||||||
let directory = directory.trim().to_string();
|
let directory = directory.trim().to_string();
|
||||||
@@ -1391,6 +1554,8 @@ pub async fn github_issues_list(
|
|||||||
connected: false,
|
connected: false,
|
||||||
repo: None,
|
repo: None,
|
||||||
issues: None,
|
issues: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if stored.access_token.trim().is_empty() {
|
if stored.access_token.trim().is_empty() {
|
||||||
@@ -1399,6 +1564,8 @@ pub async fn github_issues_list(
|
|||||||
connected: false,
|
connected: false,
|
||||||
repo: None,
|
repo: None,
|
||||||
issues: None,
|
issues: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,27 +1575,46 @@ pub async fn github_issues_list(
|
|||||||
connected: true,
|
connected: true,
|
||||||
repo: None,
|
repo: None,
|
||||||
issues: Some(vec![]),
|
issues: Some(vec![]),
|
||||||
|
page: Some(page.unwrap_or(1).max(1) as u64),
|
||||||
|
has_more: Some(false),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let page = page.unwrap_or(1).max(1);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/{}/{}/issues?state=open&per_page=50",
|
"{}/{}/{}/issues?state=open&per_page=50&page={}",
|
||||||
API_PULLS_URL_PREFIX, repo.owner, repo.repo
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, page
|
||||||
);
|
);
|
||||||
|
|
||||||
let list = github_get_json::<Vec<IssueListItem>>(&url, &stored.access_token).await;
|
let resp = reqwest::Client::new()
|
||||||
let list = match list {
|
.get(url)
|
||||||
Ok(v) => v,
|
.header("Accept", "application/vnd.github+json")
|
||||||
Err(err) if err == "unauthorized" => {
|
.header("Authorization", format!("Bearer {}", stored.access_token))
|
||||||
let _ = clear_auth_file().await;
|
.header("User-Agent", "OpenChamber")
|
||||||
return Ok(GitHubIssuesListResult {
|
.send()
|
||||||
connected: false,
|
.await
|
||||||
repo: None,
|
.map_err(|e| e.to_string())?;
|
||||||
issues: None,
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
});
|
let _ = clear_auth_file().await;
|
||||||
}
|
return Ok(GitHubIssuesListResult {
|
||||||
Err(err) => return Err(err),
|
connected: false,
|
||||||
};
|
repo: None,
|
||||||
|
issues: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub request failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
let link = resp
|
||||||
|
.headers()
|
||||||
|
.get("link")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let has_more = link.contains("rel=\"next\"");
|
||||||
|
let list = resp.json::<Vec<IssueListItem>>().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let issues = list
|
let issues = list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1447,6 +1633,8 @@ pub async fn github_issues_list(
|
|||||||
connected: true,
|
connected: true,
|
||||||
repo: Some(repo),
|
repo: Some(repo),
|
||||||
issues: Some(issues),
|
issues: Some(issues),
|
||||||
|
page: Some(page as u64),
|
||||||
|
has_more: Some(has_more),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1617,3 +1805,511 @@ pub async fn github_issue_comments(
|
|||||||
comments: Some(mapped),
|
comments: Some(mapped),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_string_field(value: &Value, key: &str) -> String {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||||
|
value.get(key).and_then(|v| v.as_bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_number_field(value: &Value, key: &str) -> Option<u64> {
|
||||||
|
value.get(key).and_then(|v| v.as_u64())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_pr_user(value: &Value) -> Option<GitHubUserSummary> {
|
||||||
|
let login = value.get("login").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if login.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GitHubUserSummary {
|
||||||
|
login: login.to_string(),
|
||||||
|
id: value.get("id").and_then(|v| v.as_u64()),
|
||||||
|
avatar_url: value
|
||||||
|
.get("avatar_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
name: None,
|
||||||
|
email: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_pr_head_repo(value: &Value) -> Option<GitHubPullRequestHeadRepo> {
|
||||||
|
let owner = value
|
||||||
|
.get("owner")
|
||||||
|
.and_then(|o| o.get("login"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let repo = value.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let url = value.get("html_url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if owner.trim().is_empty() || repo.trim().is_empty() || url.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GitHubPullRequestHeadRepo {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
url: url.to_string(),
|
||||||
|
clone_url: value
|
||||||
|
.get("clone_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn github_get_text(url: &str, access_token: &str, accept: &str) -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.header("Accept", accept)
|
||||||
|
.header("Authorization", format!("Bearer {}", access_token))
|
||||||
|
.header("User-Agent", "OpenChamber")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err("unauthorized".to_string());
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub request failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
resp.text().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn github_prs_list(
|
||||||
|
directory: String,
|
||||||
|
page: Option<u32>,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<GitHubPullRequestsListResult, String> {
|
||||||
|
let directory = directory.trim().to_string();
|
||||||
|
if directory.is_empty() {
|
||||||
|
return Err("directory is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = read_auth_file().await;
|
||||||
|
let Some(stored) = stored else {
|
||||||
|
return Ok(GitHubPullRequestsListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
prs: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if stored.access_token.trim().is_empty() {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubPullRequestsListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
prs: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = resolve_repo_from_directory(&directory).await;
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(GitHubPullRequestsListResult {
|
||||||
|
connected: true,
|
||||||
|
repo: None,
|
||||||
|
prs: Some(vec![]),
|
||||||
|
page: Some(page.unwrap_or(1).max(1) as u64),
|
||||||
|
has_more: Some(false),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = page.unwrap_or(1).max(1);
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/pulls?state=open&per_page=50&page={}",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, page
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.get(url)
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.header("Authorization", format!("Bearer {}", stored.access_token))
|
||||||
|
.header("User-Agent", "OpenChamber")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubPullRequestsListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
prs: None,
|
||||||
|
page: None,
|
||||||
|
has_more: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub request failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
let link = resp
|
||||||
|
.headers()
|
||||||
|
.get("link")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let has_more = link.contains("rel=\"next\"");
|
||||||
|
let list = resp.json::<Vec<Value>>().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let prs = list
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|pr| {
|
||||||
|
let number = read_number_field(&pr, "number")?;
|
||||||
|
let head = pr.get("head")?;
|
||||||
|
let base = pr.get("base")?;
|
||||||
|
let head_ref = read_string_field(head, "ref");
|
||||||
|
let base_ref = read_string_field(base, "ref");
|
||||||
|
let merged = read_bool_field(&pr, "merged").unwrap_or(false);
|
||||||
|
let state_raw = read_string_field(&pr, "state");
|
||||||
|
let state = if merged {
|
||||||
|
"merged".to_string()
|
||||||
|
} else if state_raw == "closed" {
|
||||||
|
"closed".to_string()
|
||||||
|
} else {
|
||||||
|
"open".to_string()
|
||||||
|
};
|
||||||
|
let head_sha = read_string_field(head, "sha");
|
||||||
|
let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) };
|
||||||
|
let mergeable = read_bool_field(&pr, "mergeable");
|
||||||
|
let mergeable_state = pr
|
||||||
|
.get("mergeable_state")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let author = pr.get("user").and_then(map_pr_user);
|
||||||
|
let head_label = head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
let head_repo = head.get("repo").and_then(map_pr_head_repo);
|
||||||
|
|
||||||
|
Some(GitHubPullRequestContext {
|
||||||
|
summary: GitHubPullRequestSummary {
|
||||||
|
number,
|
||||||
|
title: read_string_field(&pr, "title"),
|
||||||
|
url: read_string_field(&pr, "html_url"),
|
||||||
|
state,
|
||||||
|
draft: read_bool_field(&pr, "draft").unwrap_or(false),
|
||||||
|
base: base_ref,
|
||||||
|
head: head_ref,
|
||||||
|
head_sha,
|
||||||
|
mergeable,
|
||||||
|
mergeable_state,
|
||||||
|
},
|
||||||
|
author,
|
||||||
|
head_label,
|
||||||
|
head_repo,
|
||||||
|
body: None,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(GitHubPullRequestsListResult {
|
||||||
|
connected: true,
|
||||||
|
repo: Some(repo),
|
||||||
|
prs: Some(prs),
|
||||||
|
page: Some(page as u64),
|
||||||
|
has_more: Some(has_more),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn github_pr_context(
|
||||||
|
directory: String,
|
||||||
|
number: u64,
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
includeDiff: bool,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<GitHubPullRequestContextResult, String> {
|
||||||
|
let directory = directory.trim().to_string();
|
||||||
|
if directory.is_empty() {
|
||||||
|
return Err("directory is required".to_string());
|
||||||
|
}
|
||||||
|
if number == 0 {
|
||||||
|
return Err("number is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = read_auth_file().await;
|
||||||
|
let Some(stored) = stored else {
|
||||||
|
return Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
pr: None,
|
||||||
|
issue_comments: None,
|
||||||
|
review_comments: None,
|
||||||
|
files: None,
|
||||||
|
diff: None,
|
||||||
|
checks: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if stored.access_token.trim().is_empty() {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
pr: None,
|
||||||
|
issue_comments: None,
|
||||||
|
review_comments: None,
|
||||||
|
files: None,
|
||||||
|
diff: None,
|
||||||
|
checks: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = resolve_repo_from_directory(&directory).await;
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: true,
|
||||||
|
repo: None,
|
||||||
|
pr: None,
|
||||||
|
issue_comments: None,
|
||||||
|
review_comments: None,
|
||||||
|
files: None,
|
||||||
|
diff: None,
|
||||||
|
checks: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let pr_url = format!("{}/{}/{}/pulls/{}", API_PULLS_URL_PREFIX, repo.owner, repo.repo, number);
|
||||||
|
let pr_json = github_get_json::<Value>(&pr_url, &stored.access_token).await;
|
||||||
|
let pr_json = match pr_json {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) if err == "unauthorized" => {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
pr: None,
|
||||||
|
issue_comments: None,
|
||||||
|
review_comments: None,
|
||||||
|
files: None,
|
||||||
|
diff: None,
|
||||||
|
checks: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
let head = pr_json.get("head").cloned().unwrap_or(Value::Null);
|
||||||
|
let base = pr_json.get("base").cloned().unwrap_or(Value::Null);
|
||||||
|
let head_ref = read_string_field(&head, "ref");
|
||||||
|
let base_ref = read_string_field(&base, "ref");
|
||||||
|
let merged = read_bool_field(&pr_json, "merged").unwrap_or(false);
|
||||||
|
let state_raw = read_string_field(&pr_json, "state");
|
||||||
|
let state = if merged {
|
||||||
|
"merged".to_string()
|
||||||
|
} else if state_raw == "closed" {
|
||||||
|
"closed".to_string()
|
||||||
|
} else {
|
||||||
|
"open".to_string()
|
||||||
|
};
|
||||||
|
let head_sha = read_string_field(&head, "sha");
|
||||||
|
let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) };
|
||||||
|
|
||||||
|
let pr = GitHubPullRequestContext {
|
||||||
|
summary: GitHubPullRequestSummary {
|
||||||
|
number,
|
||||||
|
title: read_string_field(&pr_json, "title"),
|
||||||
|
url: read_string_field(&pr_json, "html_url"),
|
||||||
|
state,
|
||||||
|
draft: read_bool_field(&pr_json, "draft").unwrap_or(false),
|
||||||
|
base: base_ref,
|
||||||
|
head: head_ref,
|
||||||
|
head_sha,
|
||||||
|
mergeable: read_bool_field(&pr_json, "mergeable"),
|
||||||
|
mergeable_state: pr_json
|
||||||
|
.get("mergeable_state")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
},
|
||||||
|
author: pr_json.get("user").and_then(map_pr_user),
|
||||||
|
head_label: head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
head_repo: head.get("repo").and_then(map_pr_head_repo),
|
||||||
|
body: pr_json.get("body").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
created_at: pr_json.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
updated_at: pr_json.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let issue_comments_url = format!(
|
||||||
|
"{}/{}/{}/issues/{}/comments?per_page=100",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
|
||||||
|
);
|
||||||
|
let issue_comments = github_get_json::<Vec<IssueCommentResponse>>(&issue_comments_url, &stored.access_token).await?;
|
||||||
|
let issue_comments = issue_comments
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| GitHubIssueComment {
|
||||||
|
id: c.id,
|
||||||
|
url: c.html_url,
|
||||||
|
body: c.body.unwrap_or_default(),
|
||||||
|
author: c.user.as_ref().map(map_issue_user),
|
||||||
|
created_at: c.created_at,
|
||||||
|
updated_at: c.updated_at,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let review_comments_url = format!(
|
||||||
|
"{}/{}/{}/pulls/{}/comments?per_page=100",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
|
||||||
|
);
|
||||||
|
let review_comments = github_get_json::<Vec<PullReviewCommentResponse>>(&review_comments_url, &stored.access_token).await?;
|
||||||
|
let review_comments = review_comments
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| GitHubPullRequestReviewComment {
|
||||||
|
id: c.id,
|
||||||
|
url: c.html_url,
|
||||||
|
body: c.body.unwrap_or_default(),
|
||||||
|
author: c.user.as_ref().map(map_issue_user),
|
||||||
|
path: c.path,
|
||||||
|
line: c.line,
|
||||||
|
position: c.position,
|
||||||
|
created_at: c.created_at,
|
||||||
|
updated_at: c.updated_at,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let files_url = format!(
|
||||||
|
"{}/{}/{}/pulls/{}/files?per_page=100",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
|
||||||
|
);
|
||||||
|
let files = github_get_json::<Vec<PullFileResponse>>(&files_url, &stored.access_token).await?;
|
||||||
|
let files = files
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| GitHubPullRequestFile {
|
||||||
|
filename: f.filename,
|
||||||
|
status: f.status,
|
||||||
|
additions: f.additions,
|
||||||
|
deletions: f.deletions,
|
||||||
|
changes: f.changes,
|
||||||
|
patch: f.patch,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// checks summary (same as github_pr_status)
|
||||||
|
let mut checks: Option<GitHubChecksSummary> = None;
|
||||||
|
if let Some(ref sha) = pr.summary.head_sha {
|
||||||
|
let check_runs_url = format!(
|
||||||
|
"{}/{}/{}/commits/{}/check-runs",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha
|
||||||
|
);
|
||||||
|
if let Ok(runs) = github_get_json::<CheckRunsResponse>(&check_runs_url, &stored.access_token).await {
|
||||||
|
if !runs.check_runs.is_empty() {
|
||||||
|
let mut success = 0;
|
||||||
|
let mut failure = 0;
|
||||||
|
let mut pending = 0;
|
||||||
|
for run in runs.check_runs.iter() {
|
||||||
|
let status = run.status.as_deref().unwrap_or("");
|
||||||
|
let conclusion = run.conclusion.as_deref().unwrap_or("");
|
||||||
|
if status == "queued" || status == "in_progress" {
|
||||||
|
pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if conclusion.is_empty() {
|
||||||
|
pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" {
|
||||||
|
success += 1;
|
||||||
|
} else {
|
||||||
|
failure += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = success + failure + pending;
|
||||||
|
let state = if failure > 0 {
|
||||||
|
"failure"
|
||||||
|
} else if pending > 0 {
|
||||||
|
"pending"
|
||||||
|
} else if total > 0 {
|
||||||
|
"success"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
checks = Some(GitHubChecksSummary {
|
||||||
|
state: state.to_string(),
|
||||||
|
total,
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if checks.is_none() {
|
||||||
|
let status_url = format!(
|
||||||
|
"{}/{}/{}/commits/{}/status",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha
|
||||||
|
);
|
||||||
|
if let Ok(status) = github_get_json::<CombinedStatusResponse>(&status_url, &stored.access_token).await {
|
||||||
|
let mut success = 0;
|
||||||
|
let mut failure = 0;
|
||||||
|
let mut pending = 0;
|
||||||
|
for s in status.statuses.iter() {
|
||||||
|
match s.state.as_str() {
|
||||||
|
"success" => success += 1,
|
||||||
|
"failure" | "error" => failure += 1,
|
||||||
|
"pending" => pending += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = success + failure + pending;
|
||||||
|
let state = if failure > 0 {
|
||||||
|
"failure"
|
||||||
|
} else if pending > 0 {
|
||||||
|
"pending"
|
||||||
|
} else if total > 0 {
|
||||||
|
"success"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
checks = Some(GitHubChecksSummary {
|
||||||
|
state: state.to_string(),
|
||||||
|
total,
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff = if includeDiff {
|
||||||
|
let diff_text = github_get_text(&pr_url, &stored.access_token, "application/vnd.github.v3.diff").await;
|
||||||
|
match diff_text {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(err) if err == "unauthorized" => {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
pr: None,
|
||||||
|
issue_comments: None,
|
||||||
|
review_comments: None,
|
||||||
|
files: None,
|
||||||
|
diff: None,
|
||||||
|
checks: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GitHubPullRequestContextResult {
|
||||||
|
connected: true,
|
||||||
|
repo: Some(repo),
|
||||||
|
pr: Some(pr),
|
||||||
|
issue_comments: Some(issue_comments),
|
||||||
|
review_comments: Some(review_comments),
|
||||||
|
files: Some(files),
|
||||||
|
diff,
|
||||||
|
checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ use commands::logs::fetch_desktop_logs;
|
|||||||
use commands::github::{
|
use commands::github::{
|
||||||
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
|
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
|
||||||
github_issue_comments, github_issue_get, github_issues_list,
|
github_issue_comments, github_issue_get, github_issues_list,
|
||||||
|
github_pr_context, github_prs_list,
|
||||||
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
|
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
|
||||||
};
|
};
|
||||||
use commands::notifications::desktop_notify;
|
use commands::notifications::desktop_notify;
|
||||||
@@ -907,6 +908,8 @@ fn main() {
|
|||||||
github_pr_create,
|
github_pr_create,
|
||||||
github_pr_merge,
|
github_pr_merge,
|
||||||
github_pr_ready,
|
github_pr_ready,
|
||||||
|
github_prs_list,
|
||||||
|
github_pr_context,
|
||||||
github_issues_list,
|
github_issues_list,
|
||||||
github_issue_get,
|
github_issue_get,
|
||||||
github_issue_comments,
|
github_issue_comments,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
GitHubIssueCommentsResult,
|
GitHubIssueCommentsResult,
|
||||||
GitHubIssueGetResult,
|
GitHubIssueGetResult,
|
||||||
GitHubIssuesListResult,
|
GitHubIssuesListResult,
|
||||||
|
GitHubPullRequestContextResult,
|
||||||
|
GitHubPullRequestsListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -63,9 +65,9 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
|
|||||||
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
|
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory }, { timeout: 20000 });
|
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
||||||
@@ -77,4 +79,18 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
|
|||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
|
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequestsListResult>('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequestContextResult>(
|
||||||
|
'github_pr_context',
|
||||||
|
{ directory, number, includeDiff: Boolean(options?.includeDiff) },
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
|||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||||
import { createBranch } from '@/lib/gitApi';
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||||
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } from '@/lib/api/types';
|
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types';
|
||||||
|
|
||||||
const parseIssueNumber = (value: string): number | null => {
|
const parseIssueNumber = (value: string): number | null => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -48,15 +48,6 @@ const parseIssueNumber = (value: string): number | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeSlug = (value: string): string => {
|
|
||||||
return value
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, '-')
|
|
||||||
.replace(/^[-_]+|[-_]+$/g, '')
|
|
||||||
.slice(0, 80);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildIssueContextText = (args: {
|
const buildIssueContextText = (args: {
|
||||||
repo: GitHubIssuesListResult['repo'] | undefined;
|
repo: GitHubIssuesListResult['repo'] | undefined;
|
||||||
issue: GitHubIssue;
|
issue: GitHubIssue;
|
||||||
@@ -86,8 +77,12 @@ export function GitHubIssuePickerDialog({
|
|||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||||
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
|
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
|
||||||
|
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
|
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = React.useCallback(async () => {
|
||||||
@@ -105,8 +100,11 @@ export function GitHubIssuePickerDialog({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const next = await github.issuesList(projectDirectory);
|
const next = await github.issuesList(projectDirectory, { page: 1 });
|
||||||
setResult(next);
|
setResult(next);
|
||||||
|
setIssues(next.issues ?? []);
|
||||||
|
setPage(next.page ?? 1);
|
||||||
|
setHasMore(Boolean(next.hasMore));
|
||||||
if (next.connected === false) {
|
if (next.connected === false) {
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
@@ -117,6 +115,28 @@ export function GitHubIssuePickerDialog({
|
|||||||
}
|
}
|
||||||
}, [github, projectDirectory]);
|
}, [github, projectDirectory]);
|
||||||
|
|
||||||
|
const loadMore = React.useCallback(async () => {
|
||||||
|
if (!projectDirectory) return;
|
||||||
|
if (!github?.issuesList) return;
|
||||||
|
if (isLoadingMore || isLoading) return;
|
||||||
|
if (!hasMore) return;
|
||||||
|
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const next = await github.issuesList(projectDirectory, { page: nextPage });
|
||||||
|
setResult(next);
|
||||||
|
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
|
||||||
|
setPage(next.page ?? nextPage);
|
||||||
|
setHasMore(Boolean(next.hasMore));
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to load more issues', { description: message });
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
@@ -124,13 +144,15 @@ export function GitHubIssuePickerDialog({
|
|||||||
setStartingIssueNumber(null);
|
setStartingIssueNumber(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setIssues([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [open, refresh]);
|
}, [open, refresh]);
|
||||||
|
|
||||||
const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]);
|
|
||||||
const connected = Boolean(result?.connected);
|
const connected = Boolean(result?.connected);
|
||||||
const repoUrl = result?.repo?.url ?? null;
|
const repoUrl = result?.repo?.url ?? null;
|
||||||
|
|
||||||
@@ -207,29 +229,6 @@ export function GitHubIssuePickerDialog({
|
|||||||
return settingsDefaultVariant;
|
return settingsDefaultVariant;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const buildUniqueIssueBranchName = React.useCallback(
|
|
||||||
async (issue: GitHubIssue) => {
|
|
||||||
const titleSlug = sanitizeSlug(issue.title);
|
|
||||||
const base = titleSlug ? `issue-${issue.number}-${titleSlug}` : `issue-${issue.number}`;
|
|
||||||
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
||||||
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
|
|
||||||
try {
|
|
||||||
const created = await createBranch(projectDirectory || '', candidate, startPoint);
|
|
||||||
if (created?.success) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to create issue branch');
|
|
||||||
},
|
|
||||||
[baseBranch, projectDirectory]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startSession = React.useCallback(async (issueNumber: number) => {
|
const startSession = React.useCallback(async (issueNumber: number) => {
|
||||||
if (!projectDirectory) {
|
if (!projectDirectory) {
|
||||||
toast.error('No active project');
|
toast.error('No active project');
|
||||||
@@ -270,8 +269,12 @@ export function GitHubIssuePickerDialog({
|
|||||||
|
|
||||||
const sessionId = await (async () => {
|
const sessionId = await (async () => {
|
||||||
if (createInWorktree) {
|
if (createInWorktree) {
|
||||||
const branchName = await buildUniqueIssueBranchName(issue);
|
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
|
||||||
const created = await createWorktreeSessionForBranch(projectDirectory, branchName);
|
const created = await createWorktreeSessionForNewBranch(
|
||||||
|
projectDirectory,
|
||||||
|
preferred,
|
||||||
|
baseBranch || 'main'
|
||||||
|
);
|
||||||
if (!created?.id) {
|
if (!created?.id) {
|
||||||
throw new Error('Failed to create worktree session');
|
throw new Error('Failed to create worktree session');
|
||||||
}
|
}
|
||||||
@@ -350,8 +353,42 @@ export function GitHubIssuePickerDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptText =
|
const visiblePromptText = 'Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata.';
|
||||||
'Review this GitHub issue. Summarize requirements + unknowns, ask clarifying questions, gather any needed code context, then propose a plan and next actions. Do not implement until I confirm.';
|
const instructionsText = `Review this issue using the provided issue context.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
|
||||||
|
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||||||
|
- After gathering, if anything is still unclear or cannot be verified, do not speculate—state what’s missing and ask targeted questions.
|
||||||
|
|
||||||
|
Output rules:
|
||||||
|
- Compact output; pick ONE template below and omit the others.
|
||||||
|
- No emojis. No code snippets. No fenced blocks.
|
||||||
|
- Short inline code identifiers allowed.
|
||||||
|
- Reference evidence with file paths and line ranges when applicable; if exact lines aren’t available, cite the file and say “approx” + why.
|
||||||
|
- Keep the entire response under ~300 words.
|
||||||
|
|
||||||
|
Templates (choose one):
|
||||||
|
Bug:
|
||||||
|
- Summary (1-2 sentences)
|
||||||
|
- Likely cause (max 2)
|
||||||
|
- Repro/diagnostics needed (max 3)
|
||||||
|
- Fix approach (max 4 steps)
|
||||||
|
- Verification (max 3)
|
||||||
|
|
||||||
|
Feature:
|
||||||
|
- Summary (1-2 sentences)
|
||||||
|
- Requirements (max 4)
|
||||||
|
- Unknowns/questions (max 4)
|
||||||
|
- Proposed plan (max 5 steps)
|
||||||
|
- Verification (max 3)
|
||||||
|
|
||||||
|
Question/Support:
|
||||||
|
- Summary (1-2 sentences)
|
||||||
|
- Answer/guidance (max 6 lines)
|
||||||
|
- Missing info (max 4)
|
||||||
|
|
||||||
|
Do not implement changes until I confirm; end with: “Next actions: <1 sentence>”.`;
|
||||||
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
|
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
|
||||||
|
|
||||||
void opencodeClient.sendMessage({
|
void opencodeClient.sendMessage({
|
||||||
@@ -360,8 +397,11 @@ export function GitHubIssuePickerDialog({
|
|||||||
modelID,
|
modelID,
|
||||||
agent: agentName,
|
agent: agentName,
|
||||||
variant,
|
variant,
|
||||||
text: promptText,
|
text: visiblePromptText,
|
||||||
additionalParts: [{ text: contextText, synthetic: true }],
|
additionalParts: [
|
||||||
|
{ text: instructionsText, synthetic: true },
|
||||||
|
{ text: contextText, synthetic: true },
|
||||||
|
],
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
toast.error('Failed to send issue context', {
|
toast.error('Failed to send issue context', {
|
||||||
@@ -376,17 +416,7 @@ export function GitHubIssuePickerDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setStartingIssueNumber(null);
|
setStartingIssueNumber(null);
|
||||||
}
|
}
|
||||||
}, [
|
}, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||||
buildUniqueIssueBranchName,
|
|
||||||
createInWorktree,
|
|
||||||
github,
|
|
||||||
onOpenChange,
|
|
||||||
projectDirectory,
|
|
||||||
resolveDefaultAgentName,
|
|
||||||
resolveDefaultModelSelection,
|
|
||||||
resolveDefaultVariant,
|
|
||||||
startingIssueNumber,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -493,6 +523,29 @@ export function GitHubIssuePickerDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{hasMore && connected && projectDirectory && github ? (
|
||||||
|
<div className="py-2 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
disabled={isLoadingMore || Boolean(startingIssueNumber)}
|
||||||
|
className={cn(
|
||||||
|
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||||||
|
(isLoadingMore || Boolean(startingIssueNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Load more'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
RiCheckboxBlankLine,
|
||||||
|
RiCheckboxLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
RiGitPullRequestLine,
|
||||||
|
RiLoader4Line,
|
||||||
|
RiSearchLine,
|
||||||
|
} from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
|
||||||
|
import { gitFetch } from '@/lib/gitApi';
|
||||||
|
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
|
||||||
|
|
||||||
|
const parsePullRequestNumber = (value: string): number | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i);
|
||||||
|
if (urlMatch) {
|
||||||
|
const parsed = Number(urlMatch[1]);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMatch = trimmed.match(/^#?(\d+)$/);
|
||||||
|
if (hashMatch) {
|
||||||
|
const parsed = Number(hashMatch[1]);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
|
||||||
|
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GitHubPullRequestPickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { github } = useRuntimeAPIs();
|
||||||
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||||
|
|
||||||
|
const projectDirectory = activeProject?.path ?? null;
|
||||||
|
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||||
|
const [includeDiff, setIncludeDiff] = React.useState(false);
|
||||||
|
const [result, setResult] = React.useState<GitHubPullRequestsListResult | null>(null);
|
||||||
|
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
|
const [startingNumber, setStartingNumber] = React.useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
if (!projectDirectory) {
|
||||||
|
setResult(null);
|
||||||
|
setError('No active project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!github?.prsList) {
|
||||||
|
setResult(null);
|
||||||
|
setError('GitHub runtime API unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await github.prsList(projectDirectory, { page: 1 });
|
||||||
|
setResult(next);
|
||||||
|
setPrs(next.prs ?? []);
|
||||||
|
setPage(next.page ?? 1);
|
||||||
|
setHasMore(Boolean(next.hasMore));
|
||||||
|
if (next.connected === false) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [github, projectDirectory]);
|
||||||
|
|
||||||
|
const loadMore = React.useCallback(async () => {
|
||||||
|
if (!projectDirectory) return;
|
||||||
|
if (!github?.prsList) return;
|
||||||
|
if (isLoadingMore || isLoading) return;
|
||||||
|
if (!hasMore) return;
|
||||||
|
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const next = await github.prsList(projectDirectory, { page: nextPage });
|
||||||
|
setResult(next);
|
||||||
|
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
|
||||||
|
setPage(next.page ?? nextPage);
|
||||||
|
setHasMore(Boolean(next.hasMore));
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to load more PRs', { description: message });
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery('');
|
||||||
|
setCreateInWorktree(false);
|
||||||
|
setIncludeDiff(false);
|
||||||
|
setResult(null);
|
||||||
|
setPrs([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(false);
|
||||||
|
setStartingNumber(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refresh();
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
const connected = Boolean(result?.connected);
|
||||||
|
const repoUrl = result?.repo?.url ?? null;
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return prs;
|
||||||
|
return prs.filter((pr) => {
|
||||||
|
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||||
|
return pr.title.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [prs, query]);
|
||||||
|
|
||||||
|
const directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]);
|
||||||
|
|
||||||
|
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
const visibleAgents = configState.getVisibleAgents();
|
||||||
|
|
||||||
|
if (configState.settingsDefaultAgent) {
|
||||||
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||||
|
if (settingsAgent) {
|
||||||
|
return settingsAgent.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => {
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||||
|
if (!settingsDefaultModel) return null;
|
||||||
|
|
||||||
|
const parts = settingsDefaultModel.split('/');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [providerID, modelID] = parts;
|
||||||
|
if (!providerID || !modelID) return null;
|
||||||
|
|
||||||
|
const modelMetadata = configState.getModelMetadata(providerID, modelID);
|
||||||
|
if (!modelMetadata) return null;
|
||||||
|
return { providerID, modelID };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||||
|
if (!settingsDefaultVariant) return undefined;
|
||||||
|
|
||||||
|
const provider = configState.providers.find((p) => p.id === providerID);
|
||||||
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
|
||||||
|
| { variants?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
const variants = model?.variants;
|
||||||
|
if (!variants) return undefined;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined;
|
||||||
|
return settingsDefaultVariant;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createPrWorktreeSession = React.useCallback(async (
|
||||||
|
baseRepo: GitHubPullRequestsListResult['repo'] | undefined,
|
||||||
|
pr: GitHubPullRequestSummary,
|
||||||
|
): Promise<{ id: string } | null> => {
|
||||||
|
if (!projectDirectory) return null;
|
||||||
|
const headRef = pr.head;
|
||||||
|
const headRepo = pr.headRepo;
|
||||||
|
if (!headRef) {
|
||||||
|
throw new Error('PR head ref missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFork = Boolean(
|
||||||
|
headRepo?.owner && headRepo?.repo &&
|
||||||
|
baseRepo?.owner && baseRepo?.repo &&
|
||||||
|
(headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo)
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchRemote = isFork
|
||||||
|
? (headRepo?.cloneUrl || headRepo?.url || '')
|
||||||
|
: 'origin';
|
||||||
|
if (!fetchRemote) {
|
||||||
|
throw new Error('PR head remote URL missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRef = `refs/heads/${headRef}`;
|
||||||
|
const fetchResult = await gitFetch(projectDirectory, { remote: fetchRemote, branch: fetchRef });
|
||||||
|
if (!fetchResult?.success) {
|
||||||
|
throw new Error('Failed to fetch PR head');
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredBranch = pr.head;
|
||||||
|
const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, 'FETCH_HEAD');
|
||||||
|
if (!session?.id) {
|
||||||
|
throw new Error('Failed to create PR worktree session');
|
||||||
|
}
|
||||||
|
return { id: session.id };
|
||||||
|
}, [projectDirectory]);
|
||||||
|
|
||||||
|
const startSession = React.useCallback(async (number: number) => {
|
||||||
|
if (!projectDirectory) {
|
||||||
|
toast.error('No active project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!github?.prContext) {
|
||||||
|
toast.error('GitHub runtime API unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startingNumber) return;
|
||||||
|
setStartingNumber(number);
|
||||||
|
try {
|
||||||
|
const prContext = await github.prContext(projectDirectory, number, { includeDiff });
|
||||||
|
if (prContext.connected === false) {
|
||||||
|
toast.error('GitHub not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prContext.repo) {
|
||||||
|
toast.error('Repo not resolvable', { description: 'origin remote must be a GitHub URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prContext.pr) {
|
||||||
|
toast.error('PR not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = prContext.pr;
|
||||||
|
const sessionTitle = `#${pr.number} ${pr.title}`.trim();
|
||||||
|
|
||||||
|
const sessionId = await (async () => {
|
||||||
|
if (createInWorktree) {
|
||||||
|
try {
|
||||||
|
const worktreeSession = await createPrWorktreeSession(prContext.repo, pr);
|
||||||
|
return worktreeSession?.id || null;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('PR worktree failed', { description: msg });
|
||||||
|
// fall back to normal session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
|
||||||
|
return session?.id || null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error('Failed to create session');
|
||||||
|
}
|
||||||
|
|
||||||
|
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
||||||
|
try {
|
||||||
|
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
||||||
|
const defaultModel = resolveDefaultModelSelection();
|
||||||
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
||||||
|
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
||||||
|
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
|
||||||
|
if (!providerID || !modelID) {
|
||||||
|
toast.error('No model selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = resolveDefaultVariant(providerID, modelID);
|
||||||
|
try {
|
||||||
|
useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName) {
|
||||||
|
try {
|
||||||
|
configState.setAgent(agentName);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
useContextStore.getState().saveSessionAgentSelection(sessionId, agentName);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (variant !== undefined) {
|
||||||
|
try {
|
||||||
|
configState.setCurrentVariant(variant);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePromptText = 'Review this pull request using the provided PR context: description, comments, files, diff, checks.';
|
||||||
|
const instructionsText = `Before reporting issues:
|
||||||
|
- First identify the PR intent (what it’s trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep.
|
||||||
|
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||||||
|
- No speculation: if something is unclear or cannot be verified, say what’s missing and ask for it instead of guessing.
|
||||||
|
|
||||||
|
Output rules:
|
||||||
|
- Start with a 1-2 sentence summary.
|
||||||
|
- Provide a single concise PR review comment.
|
||||||
|
- No emojis. No code snippets. No fenced blocks.
|
||||||
|
- Short inline code identifiers allowed, but no snippets or fenced blocks.
|
||||||
|
- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren’t available, cite the file and say “approx” + why.
|
||||||
|
- Keep the entire comment under ~300 words.
|
||||||
|
|
||||||
|
Report:
|
||||||
|
- Must-fix issues (blocking) — brief why and a one-line action each.
|
||||||
|
- Nice-to-have improvements (optional) — brief why and a one-line action each.
|
||||||
|
|
||||||
|
Quality & safety (general):
|
||||||
|
- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks.
|
||||||
|
- Call out missing tests/verification steps and suggest the minimal validation needed.
|
||||||
|
- Note readability/maintainability issues when they materially affect future changes.
|
||||||
|
|
||||||
|
Applicability (only if relevant):
|
||||||
|
- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility).
|
||||||
|
|
||||||
|
Precedence:
|
||||||
|
- If local precedent conflicts with best practices, state it and suggest a follow-up task.
|
||||||
|
|
||||||
|
Do not implement changes until I confirm; end with a short “Next actions” sentence describing the recommended plan.
|
||||||
|
|
||||||
|
Format exactly:
|
||||||
|
Must-fix:
|
||||||
|
- <issue> — <brief why> — <file:line-range> — Action: <one-line action>
|
||||||
|
Nice-to-have:
|
||||||
|
- <issue> — <brief why> — <file:line-range> — Action: <one-line action>
|
||||||
|
If no issues, write:
|
||||||
|
Must-fix:
|
||||||
|
- None
|
||||||
|
Nice-to-have:
|
||||||
|
- None`;
|
||||||
|
const contextText = buildPullRequestContextText(prContext);
|
||||||
|
|
||||||
|
void opencodeClient.sendMessage({
|
||||||
|
id: sessionId,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
agent: agentName,
|
||||||
|
variant,
|
||||||
|
text: visiblePromptText,
|
||||||
|
additionalParts: [
|
||||||
|
{ text: instructionsText, synthetic: true },
|
||||||
|
{ text: contextText, synthetic: true },
|
||||||
|
],
|
||||||
|
}).catch((e) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to send PR context', { description: message });
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Session created from PR');
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to start session', { description: message });
|
||||||
|
} finally {
|
||||||
|
setStartingNumber(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
createInWorktree,
|
||||||
|
createPrWorktreeSession,
|
||||||
|
github,
|
||||||
|
includeDiff,
|
||||||
|
onOpenChange,
|
||||||
|
projectDirectory,
|
||||||
|
resolveDefaultAgentName,
|
||||||
|
resolveDefaultModelSelection,
|
||||||
|
resolveDefaultVariant,
|
||||||
|
startingNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RiGitPullRequestLine className="h-5 w-5" />
|
||||||
|
New Session From GitHub PR
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Seeds a new session with hidden PR context (title/body/comments/files/checks).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by title or #123, or paste PR URL"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-9 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto mt-2">
|
||||||
|
{!projectDirectory ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!github ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
Loading pull requests...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{connected === false ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">GitHub not connected.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{directNumber && projectDirectory && github && connected ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
||||||
|
startingNumber === directNumber && 'bg-muted/30'
|
||||||
|
)}
|
||||||
|
onClick={() => void startSession(directNumber)}
|
||||||
|
>
|
||||||
|
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
|
||||||
|
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||||
|
Use PR #{directNumber}
|
||||||
|
</p>
|
||||||
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||||
|
{startingNumber === directNumber ? (
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">{query ? 'No PRs found' : 'No open PRs found'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filtered.map((pr) => (
|
||||||
|
<div
|
||||||
|
key={pr.number}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
||||||
|
startingNumber === pr.number && 'bg-muted/30'
|
||||||
|
)}
|
||||||
|
onClick={() => void startSession(pr.number)}
|
||||||
|
>
|
||||||
|
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">#{pr.number}</span>
|
||||||
|
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
||||||
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||||
|
{startingNumber === pr.number ? (
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={pr.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label="Open in GitHub"
|
||||||
|
>
|
||||||
|
<RiExternalLinkLine className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && connected && projectDirectory && github ? (
|
||||||
|
<div className="py-2 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
disabled={isLoadingMore || Boolean(startingNumber)}
|
||||||
|
className={cn(
|
||||||
|
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||||||
|
(isLoadingMore || Boolean(startingNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Load more'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||||
|
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={createInWorktree}
|
||||||
|
onClick={() => setCreateInWorktree((v) => !v)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateInWorktree((v) => !v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCreateInWorktree((v) => !v);
|
||||||
|
}}
|
||||||
|
aria-label="Toggle worktree"
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
{createInWorktree ? (
|
||||||
|
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankLine className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="typography-meta text-muted-foreground">Create session in PR worktree</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={includeDiff}
|
||||||
|
onClick={() => setIncludeDiff((v) => !v)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIncludeDiff((v) => !v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIncludeDiff((v) => !v);
|
||||||
|
}}
|
||||||
|
aria-label="Toggle diff"
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
{includeDiff ? (
|
||||||
|
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankLine className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="typography-meta text-muted-foreground">Include full diff</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
{repoUrl ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
Open Repo
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingNumber)}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
|
RiGitPullRequestLine,
|
||||||
RiGitRepositoryLine,
|
RiGitRepositoryLine,
|
||||||
RiLinkUnlinkM,
|
RiLinkUnlinkM,
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
|||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||||
|
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||||
|
|
||||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||||
@@ -143,6 +145,7 @@ interface SortableProjectItemProps {
|
|||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
onOpenBranchPicker?: () => void;
|
onOpenBranchPicker?: () => void;
|
||||||
onNewSessionFromGitHubIssue?: () => void;
|
onNewSessionFromGitHubIssue?: () => void;
|
||||||
|
onNewSessionFromGitHubPR?: () => void;
|
||||||
onOpenMultiRunLauncher: () => void;
|
onOpenMultiRunLauncher: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||||
@@ -168,6 +171,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
onOpenBranchPicker,
|
onOpenBranchPicker,
|
||||||
onNewSessionFromGitHubIssue,
|
onNewSessionFromGitHubIssue,
|
||||||
|
onNewSessionFromGitHubPR,
|
||||||
onOpenMultiRunLauncher,
|
onOpenMultiRunLauncher,
|
||||||
onClose,
|
onClose,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
@@ -284,6 +288,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
New session from GitHub issue
|
New session from GitHub issue
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
|
||||||
|
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
|
||||||
|
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
|
||||||
|
New session from GitHub PR
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isRepo && !hideDirectoryControls && (
|
{isRepo && !hideDirectoryControls && (
|
||||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||||
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
||||||
@@ -411,6 +421,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||||
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||||
|
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||||
@@ -1653,6 +1664,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
setIssuePickerOpen(true);
|
setIssuePickerOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onNewSessionFromGitHubPR={() => {
|
||||||
|
if (projectKey !== activeProjectId) {
|
||||||
|
setActiveProject(projectKey);
|
||||||
|
}
|
||||||
|
setActiveMainTab('chat');
|
||||||
|
if (mobileVariant) {
|
||||||
|
setSessionSwitcherOpen(false);
|
||||||
|
}
|
||||||
|
setPullRequestPickerOpen(true);
|
||||||
|
}}
|
||||||
onOpenMultiRunLauncher={() => {
|
onOpenMultiRunLauncher={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProject(projectKey);
|
||||||
@@ -1699,6 +1720,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
open={issuePickerOpen}
|
open={issuePickerOpen}
|
||||||
onOpenChange={setIssuePickerOpen}
|
onOpenChange={setIssuePickerOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GitHubPullRequestPickerDialog
|
||||||
|
open={pullRequestPickerOpen}
|
||||||
|
onOpenChange={setPullRequestPickerOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -522,6 +522,62 @@ export type GitHubPullRequest = {
|
|||||||
mergeableState?: string | null;
|
mergeableState?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestHeadRepo = {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
url: string;
|
||||||
|
cloneUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestSummary = GitHubPullRequest & {
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
body?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
headLabel?: string;
|
||||||
|
headRepo?: GitHubPullRequestHeadRepo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestFile = {
|
||||||
|
filename: string;
|
||||||
|
status?: string;
|
||||||
|
additions?: number;
|
||||||
|
deletions?: number;
|
||||||
|
changes?: number;
|
||||||
|
patch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestReviewComment = {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
path?: string;
|
||||||
|
line?: number | null;
|
||||||
|
position?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestsListResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
prs?: GitHubPullRequestSummary[];
|
||||||
|
page?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestContextResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
pr?: GitHubPullRequestSummary | null;
|
||||||
|
issueComments?: GitHubIssueComment[];
|
||||||
|
reviewComments?: GitHubPullRequestReviewComment[];
|
||||||
|
files?: GitHubPullRequestFile[];
|
||||||
|
diff?: string;
|
||||||
|
checks?: GitHubChecksSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GitHubPullRequestStatus = {
|
export type GitHubPullRequestStatus = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
repo?: GitHubRepoRef | null;
|
repo?: GitHubRepoRef | null;
|
||||||
@@ -594,6 +650,8 @@ export type GitHubIssuesListResult = {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
repo?: GitHubRepoRef | null;
|
repo?: GitHubRepoRef | null;
|
||||||
issues?: GitHubIssueSummary[];
|
issues?: GitHubIssueSummary[];
|
||||||
|
page?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubIssueGetResult = {
|
export type GitHubIssueGetResult = {
|
||||||
@@ -640,7 +698,10 @@ export interface GitHubAPI {
|
|||||||
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||||
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
||||||
|
|
||||||
issuesList(directory: string): Promise<GitHubIssuesListResult>;
|
prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult>;
|
||||||
|
prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult>;
|
||||||
|
|
||||||
|
issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult>;
|
||||||
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
|
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
|
||||||
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
|
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,3 +438,197 @@ export async function createWorktreeSessionForBranch(
|
|||||||
isCreatingWorktreeSession = false;
|
isCreatingWorktreeSession = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a worktree session for a new branch (created at startPoint).
|
||||||
|
* This avoids checking out the branch in the main worktree.
|
||||||
|
*/
|
||||||
|
export async function createWorktreeSessionForNewBranch(
|
||||||
|
projectDirectory: string,
|
||||||
|
preferredBranchName: string,
|
||||||
|
startPoint: string,
|
||||||
|
options?: { allowSuffix?: boolean }
|
||||||
|
): Promise<{ id: string; branch: string } | null> {
|
||||||
|
if (isCreatingWorktreeSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isGitRepo = false;
|
||||||
|
try {
|
||||||
|
isGitRepo = await checkIsGitRepository(projectDirectory);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGitRepo) {
|
||||||
|
toast.error('Not a Git repository', {
|
||||||
|
description: 'Worktrees can only be created in Git repositories.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingWorktreeSession = true;
|
||||||
|
startConfigUpdate('Creating worktree session...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = startPoint?.trim() || 'HEAD';
|
||||||
|
const base = preferredBranchName?.trim();
|
||||||
|
if (!base) {
|
||||||
|
throw new Error('Branch name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown = null;
|
||||||
|
const allowSuffix = options?.allowSuffix !== false;
|
||||||
|
const maxAttempts = allowSuffix ? 6 : 1;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
|
||||||
|
try {
|
||||||
|
const worktreeSlug = sanitizeWorktreeSlug(candidate);
|
||||||
|
const metadata = await createWorktree({
|
||||||
|
projectDirectory,
|
||||||
|
worktreeSlug,
|
||||||
|
branch: candidate,
|
||||||
|
createBranch: true,
|
||||||
|
startPoint: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||||
|
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore.getState();
|
||||||
|
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||||
|
if (!session) {
|
||||||
|
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
|
||||||
|
throw new Error('Could not create a session for the worktree.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configState = useConfigStore.getState();
|
||||||
|
sessionStore.initializeNewOpenChamberSession(session.id, configState.agents);
|
||||||
|
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||||
|
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||||
|
|
||||||
|
// Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch)
|
||||||
|
try {
|
||||||
|
const visibleAgents = configState.getVisibleAgents();
|
||||||
|
let agentName: string | undefined;
|
||||||
|
if (configState.settingsDefaultAgent) {
|
||||||
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
||||||
|
if (settingsAgent) {
|
||||||
|
agentName = settingsAgent.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!agentName) {
|
||||||
|
agentName =
|
||||||
|
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
||||||
|
visibleAgents[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName) {
|
||||||
|
configState.setAgent(agentName);
|
||||||
|
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
|
||||||
|
|
||||||
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||||
|
if (settingsDefaultModel) {
|
||||||
|
const parts = settingsDefaultModel.split('/');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [providerId, modelId] = parts;
|
||||||
|
const modelMetadata = configState.getModelMetadata(providerId, modelId);
|
||||||
|
if (modelMetadata) {
|
||||||
|
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
|
||||||
|
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
|
||||||
|
|
||||||
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||||
|
if (settingsDefaultVariant) {
|
||||||
|
const provider = configState.providers.find((p) => p.id === providerId);
|
||||||
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelId) as
|
||||||
|
| { variants?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
const variants = model?.variants;
|
||||||
|
if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
|
||||||
|
configState.setCurrentVariant(settingsDefaultVariant);
|
||||||
|
useContextStore
|
||||||
|
.getState()
|
||||||
|
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||||
|
try {
|
||||||
|
await sessionStore.loadSessions();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and run setup commands
|
||||||
|
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
|
||||||
|
const commandsToRun = setupCommands.filter((cmd) => cmd.trim().length > 0);
|
||||||
|
|
||||||
|
if (commandsToRun.length > 0) {
|
||||||
|
toast.success('Worktree created', {
|
||||||
|
description: `Branch: ${candidate}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run setup commands in background
|
||||||
|
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Setup commands completed', {
|
||||||
|
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const failed = result.results.filter((r) => !r.success);
|
||||||
|
const succeeded = result.results.filter((r) => r.success);
|
||||||
|
toast.error('Setup commands failed', {
|
||||||
|
description:
|
||||||
|
`${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
|
||||||
|
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Setup commands failed', {
|
||||||
|
description: 'Could not execute setup commands.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Worktree created', {
|
||||||
|
description: `Branch: ${candidate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: session.id, branch: candidate };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree session';
|
||||||
|
toast.error('Failed to create worktree', {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
finishConfigUpdate();
|
||||||
|
isCreatingWorktreeSession = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name.
|
||||||
|
* Use when the worktree must be created on an exact branch name (e.g. PR head ref).
|
||||||
|
*/
|
||||||
|
export async function createWorktreeSessionForNewBranchExact(
|
||||||
|
projectDirectory: string,
|
||||||
|
branchName: string,
|
||||||
|
startPoint: string
|
||||||
|
): Promise<{ id: string; branch: string } | null> {
|
||||||
|
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { allowSuffix: false });
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
listIssues,
|
listIssues,
|
||||||
} from './githubIssues';
|
} from './githubIssues';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPullRequestContext,
|
||||||
|
listPullRequests,
|
||||||
|
} from './githubPulls';
|
||||||
|
|
||||||
export interface BridgeRequest {
|
export interface BridgeRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -1185,11 +1190,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
return { id, type, success: true, data: { connected: false } };
|
return { id, type, success: true, data: { connected: false } };
|
||||||
}
|
}
|
||||||
const directory = readStringField(payload, 'directory');
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const page = readNumberField(payload, 'page') ?? 1;
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
return { id, type, success: false, error: 'directory is required' };
|
return { id, type, success: false, error: 'directory is required' };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await listIssues(stored.accessToken, directory);
|
const result = await listIssues(stored.accessToken, directory, page);
|
||||||
if (result.connected === false) {
|
if (result.connected === false) {
|
||||||
await clearGitHubAuth(context);
|
await clearGitHubAuth(context);
|
||||||
}
|
}
|
||||||
@@ -1248,6 +1254,55 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:github/pulls:list': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) {
|
||||||
|
return { id, type, success: true, data: { connected: false } };
|
||||||
|
}
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const page = readNumberField(payload, 'page') ?? 1;
|
||||||
|
if (!directory) {
|
||||||
|
return { id, type, success: false, error: 'directory is required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await listPullRequests(stored.accessToken, directory, page);
|
||||||
|
if (result.connected === false) {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
}
|
||||||
|
return { id, type, success: true, data: result };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/pulls:context': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) {
|
||||||
|
return { id, type, success: true, data: { connected: false } };
|
||||||
|
}
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const number = readNumberField(payload, 'number') ?? 0;
|
||||||
|
const includeDiff = readBooleanField(payload, 'includeDiff') ?? false;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return { id, type, success: false, error: 'directory and number are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff);
|
||||||
|
if (result.connected === false) {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
}
|
||||||
|
return { id, type, success: true, data: result };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:config/reload': {
|
case 'api:config/reload': {
|
||||||
await ctx?.manager?.restart();
|
await ctx?.manager?.restart();
|
||||||
return { id, type, success: true, data: { restarted: true } };
|
return { id, type, success: true, data: { restarted: true } };
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type GitHubIssuesListResult = {
|
|||||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||||
labels?: Array<{ name: string; color?: string }>;
|
labels?: Array<{ name: string; color?: string }>;
|
||||||
}>;
|
}>;
|
||||||
|
page?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GitHubIssueGetResult = {
|
type GitHubIssueGetResult = {
|
||||||
@@ -102,6 +104,7 @@ const mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => {
|
|||||||
export const listIssues = async (
|
export const listIssues = async (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
|
page: number = 1,
|
||||||
): Promise<GitHubIssuesListResult> => {
|
): Promise<GitHubIssuesListResult> => {
|
||||||
const repo = await resolveRepoFromDirectory(directory);
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
@@ -111,12 +114,16 @@ export const listIssues = async (
|
|||||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
||||||
url.searchParams.set('state', 'open');
|
url.searchParams.set('state', 'open');
|
||||||
url.searchParams.set('per_page', '50');
|
url.searchParams.set('per_page', '50');
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
const resp = await githubFetch(url.toString(), accessToken);
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
return { connected: false };
|
return { connected: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const link = resp.headers.get('link') || '';
|
||||||
|
const hasMore = /rel="next"/.test(link);
|
||||||
|
|
||||||
const json = await jsonOrNull<unknown[]>(resp);
|
const json = await jsonOrNull<unknown[]>(resp);
|
||||||
if (!resp.ok || !Array.isArray(json)) {
|
if (!resp.ok || !Array.isArray(json)) {
|
||||||
throw new Error('Failed to load issues');
|
throw new Error('Failed to load issues');
|
||||||
@@ -140,7 +147,7 @@ export const listIssues = async (
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||||
|
|
||||||
return { connected: true, repo, issues: issues || [] };
|
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getIssue = async (
|
export const getIssue = async (
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { resolveRepoFromDirectory } from './githubPr';
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.github.com';
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||||
|
|
||||||
|
type GitHubUserSummary = { login: string; id?: number; avatarUrl?: string; name?: string; email?: string };
|
||||||
|
|
||||||
|
type GitHubChecksSummary = {
|
||||||
|
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string };
|
||||||
|
|
||||||
|
type GitHubPullRequestSummary = {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed' | 'merged';
|
||||||
|
draft: boolean;
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
headSha?: string;
|
||||||
|
mergeable?: boolean | null;
|
||||||
|
mergeableState?: string | null;
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
body?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
headLabel?: string;
|
||||||
|
headRepo?: GitHubPullRequestHeadRepo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubIssueComment = {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestReviewComment = {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
path?: string;
|
||||||
|
line?: number | null;
|
||||||
|
position?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestFile = {
|
||||||
|
filename: string;
|
||||||
|
status?: string;
|
||||||
|
additions?: number;
|
||||||
|
deletions?: number;
|
||||||
|
changes?: number;
|
||||||
|
patch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestsListResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
prs?: GitHubPullRequestSummary[];
|
||||||
|
page?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestContextResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
pr?: GitHubPullRequestSummary | null;
|
||||||
|
issueComments?: GitHubIssueComment[];
|
||||||
|
reviewComments?: GitHubPullRequestReviewComment[];
|
||||||
|
files?: GitHubPullRequestFile[];
|
||||||
|
diff?: string;
|
||||||
|
checks?: GitHubChecksSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubFetch = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubFetchText = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string,
|
||||||
|
accept: string,
|
||||||
|
): Promise<Response> => {
|
||||||
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: accept,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||||
|
return (await response.json().catch(() => null)) as T | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||||
|
|
||||||
|
const mapUser = (raw: unknown): GitHubUserSummary | null => {
|
||||||
|
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||||
|
const login = readString(rec?.login);
|
||||||
|
if (!login) return null;
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||||
|
avatarUrl: readString(rec?.avatar_url) || undefined,
|
||||||
|
name: undefined,
|
||||||
|
email: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => {
|
||||||
|
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||||
|
const ownerLogin = readString((rec?.owner as JsonRecord | undefined)?.login);
|
||||||
|
const repo = readString(rec?.name);
|
||||||
|
const url = readString(rec?.html_url);
|
||||||
|
if (!ownerLogin || !repo || !url) return null;
|
||||||
|
return {
|
||||||
|
owner: ownerLogin,
|
||||||
|
repo,
|
||||||
|
url,
|
||||||
|
cloneUrl: readString(rec?.clone_url) || undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeChecks = async (accessToken: string, repo: GitHubRepoRef, sha: string): Promise<GitHubChecksSummary | null> => {
|
||||||
|
const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken);
|
||||||
|
const runsJson = await jsonOrNull<JsonRecord>(runsResp);
|
||||||
|
const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
|
||||||
|
? ((runsJson as JsonRecord).check_runs as unknown[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (runsResp.ok && runs.length > 0) {
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
runs.forEach((r) => {
|
||||||
|
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||||
|
const status = readString(rec?.status);
|
||||||
|
const conclusion = readString(rec?.conclusion);
|
||||||
|
if (status === 'queued' || status === 'in_progress') {
|
||||||
|
counts.pending += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!conclusion) {
|
||||||
|
counts.pending += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||||
|
counts.success += 1;
|
||||||
|
} else {
|
||||||
|
counts.failure += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state = counts.failure > 0
|
||||||
|
? 'failure'
|
||||||
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
return { state, total, ...counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken);
|
||||||
|
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||||
|
if (!statusResp.ok || !statusJson) return null;
|
||||||
|
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
statuses.forEach((s) => {
|
||||||
|
const st = readString((s as JsonRecord | null)?.state);
|
||||||
|
if (st === 'success') counts.success += 1;
|
||||||
|
else if (st === 'failure' || st === 'error') counts.failure += 1;
|
||||||
|
else if (st === 'pending') counts.pending += 1;
|
||||||
|
});
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state = counts.failure > 0
|
||||||
|
? 'failure'
|
||||||
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
return { state, total, ...counts };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listPullRequests = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
page: number = 1,
|
||||||
|
): Promise<GitHubPullRequestsListResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, prs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||||
|
url.searchParams.set('state', 'open');
|
||||||
|
url.searchParams.set('per_page', '50');
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
|
if (resp.status === 401) return { connected: false };
|
||||||
|
|
||||||
|
const link = resp.headers.get('link') || '';
|
||||||
|
const hasMore = /rel="next"/.test(link);
|
||||||
|
const json = await jsonOrNull<unknown[]>(resp);
|
||||||
|
if (!resp.ok || !Array.isArray(json)) throw new Error('Failed to load PRs');
|
||||||
|
|
||||||
|
const prs = json.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||||
|
const mergedAt = readString(rec?.merged_at);
|
||||||
|
const stateRaw = readString(rec?.state);
|
||||||
|
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||||
|
|
||||||
|
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||||
|
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
number,
|
||||||
|
title: readString(rec?.title) || '',
|
||||||
|
url: readString(rec?.html_url) || '',
|
||||||
|
state,
|
||||||
|
draft: Boolean(rec?.draft),
|
||||||
|
base: readString(base?.ref) || '',
|
||||||
|
head: readString(head?.ref) || '',
|
||||||
|
headSha: readString(head?.sha) || undefined,
|
||||||
|
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||||
|
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||||
|
author: mapUser(rec?.user),
|
||||||
|
headLabel: readString(head?.label) || undefined,
|
||||||
|
headRepo: mapHeadRepo(head?.repo),
|
||||||
|
} as GitHubPullRequestSummary;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { connected: true, repo, prs, page, hasMore };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPullRequestContext = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
number: number,
|
||||||
|
includeDiff: boolean,
|
||||||
|
): Promise<GitHubPullRequestContextResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, pr: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||||
|
if (prResp.status === 401) return { connected: false };
|
||||||
|
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||||
|
if (!prResp.ok || !prJson) throw new Error('Failed to load PR');
|
||||||
|
|
||||||
|
const merged = Boolean(prJson.merged_at) || Boolean(prJson.merged);
|
||||||
|
const prState = readString(prJson.state);
|
||||||
|
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||||
|
const base = prJson.base && typeof prJson.base === 'object' ? (prJson.base as JsonRecord) : null;
|
||||||
|
const head = prJson.head && typeof prJson.head === 'object' ? (prJson.head as JsonRecord) : null;
|
||||||
|
|
||||||
|
const pr: GitHubPullRequestSummary = {
|
||||||
|
number: typeof prJson.number === 'number' ? prJson.number : number,
|
||||||
|
title: readString(prJson.title) || '',
|
||||||
|
url: readString(prJson.html_url) || '',
|
||||||
|
state,
|
||||||
|
draft: Boolean(prJson.draft),
|
||||||
|
base: readString(base?.ref) || '',
|
||||||
|
head: readString(head?.ref) || '',
|
||||||
|
headSha: readString(head?.sha) || undefined,
|
||||||
|
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||||
|
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||||
|
author: mapUser(prJson.user),
|
||||||
|
headLabel: readString(head?.label) || undefined,
|
||||||
|
headRepo: mapHeadRepo(head?.repo),
|
||||||
|
body: readString(prJson.body) || '',
|
||||||
|
createdAt: readString(prJson.created_at) || undefined,
|
||||||
|
updatedAt: readString(prJson.updated_at) || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments?per_page=100`, accessToken);
|
||||||
|
if (issueCommentsResp.status === 401) return { connected: false };
|
||||||
|
const issueCommentsJson = await jsonOrNull<unknown[]>(issueCommentsResp);
|
||||||
|
if (!issueCommentsResp.ok || !Array.isArray(issueCommentsJson)) throw new Error('Failed to load PR issue comments');
|
||||||
|
const issueComments: GitHubIssueComment[] = issueCommentsJson
|
||||||
|
.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||||
|
if (!id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url: readString(rec?.html_url) || '',
|
||||||
|
body: readString(rec?.body) || '',
|
||||||
|
author: mapUser(rec?.user),
|
||||||
|
createdAt: readString(rec?.created_at) || undefined,
|
||||||
|
updatedAt: readString(rec?.updated_at) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as GitHubIssueComment[];
|
||||||
|
|
||||||
|
const reviewCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/comments?per_page=100`, accessToken);
|
||||||
|
if (reviewCommentsResp.status === 401) return { connected: false };
|
||||||
|
const reviewCommentsJson = await jsonOrNull<unknown[]>(reviewCommentsResp);
|
||||||
|
if (!reviewCommentsResp.ok || !Array.isArray(reviewCommentsJson)) throw new Error('Failed to load PR review comments');
|
||||||
|
const reviewComments: GitHubPullRequestReviewComment[] = reviewCommentsJson
|
||||||
|
.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||||
|
if (!id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url: readString(rec?.html_url) || '',
|
||||||
|
body: readString(rec?.body) || '',
|
||||||
|
author: mapUser(rec?.user),
|
||||||
|
path: readString(rec?.path) || undefined,
|
||||||
|
line: typeof rec?.line === 'number' ? rec.line : null,
|
||||||
|
position: typeof rec?.position === 'number' ? rec.position : null,
|
||||||
|
createdAt: readString(rec?.created_at) || undefined,
|
||||||
|
updatedAt: readString(rec?.updated_at) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as GitHubPullRequestReviewComment[];
|
||||||
|
|
||||||
|
const filesResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/files?per_page=100`, accessToken);
|
||||||
|
if (filesResp.status === 401) return { connected: false };
|
||||||
|
const filesJson = await jsonOrNull<unknown[]>(filesResp);
|
||||||
|
if (!filesResp.ok || !Array.isArray(filesJson)) throw new Error('Failed to load PR files');
|
||||||
|
const files: GitHubPullRequestFile[] = filesJson
|
||||||
|
.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
const filename = readString(rec?.filename);
|
||||||
|
if (!filename) return null;
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
status: readString(rec?.status) || undefined,
|
||||||
|
additions: typeof rec?.additions === 'number' ? rec.additions : undefined,
|
||||||
|
deletions: typeof rec?.deletions === 'number' ? rec.deletions : undefined,
|
||||||
|
changes: typeof rec?.changes === 'number' ? rec.changes : undefined,
|
||||||
|
patch: readString(rec?.patch) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as GitHubPullRequestFile[];
|
||||||
|
|
||||||
|
const checks = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : null;
|
||||||
|
|
||||||
|
let diff: string | undefined;
|
||||||
|
if (includeDiff) {
|
||||||
|
const diffResp = await githubFetchText(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken, 'application/vnd.github.v3.diff');
|
||||||
|
if (diffResp.status === 401) return { connected: false };
|
||||||
|
if (diffResp.ok) {
|
||||||
|
diff = await diffResp.text().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks };
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
GitHubIssueCommentsResult,
|
GitHubIssueCommentsResult,
|
||||||
GitHubIssueGetResult,
|
GitHubIssueGetResult,
|
||||||
GitHubIssuesListResult,
|
GitHubIssuesListResult,
|
||||||
|
GitHubPullRequestContextResult,
|
||||||
|
GitHubPullRequestsListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -35,10 +37,15 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
|||||||
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
||||||
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
|
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
|
||||||
|
|
||||||
issuesList: async (directory: string) =>
|
issuesList: async (directory: string, options?: { page?: number }) =>
|
||||||
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory }),
|
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory, page: options?.page ?? 1 }),
|
||||||
issueGet: async (directory: string, number: number) =>
|
issueGet: async (directory: string, number: number) =>
|
||||||
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number }),
|
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number }),
|
||||||
issueComments: async (directory: string, number: number) =>
|
issueComments: async (directory: string, number: number) =>
|
||||||
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number }),
|
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number }),
|
||||||
|
|
||||||
|
prsList: async (directory: string, options?: { page?: number }) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequestsListResult>('api:github/pulls:list', { directory, page: options?.page ?? 1 }),
|
||||||
|
prContext: async (directory: string, number: number, options?: { includeDiff?: boolean }) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequestContextResult>('api:github/pulls:context', { directory, number, includeDiff: Boolean(options?.includeDiff) }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4365,6 +4365,7 @@ async function main(options = {}) {
|
|||||||
app.get('/api/github/issues/list', async (req, res) => {
|
app.get('/api/github/issues/list', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||||
|
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
return res.status(400).json({ error: 'directory is required' });
|
return res.status(400).json({ error: 'directory is required' });
|
||||||
}
|
}
|
||||||
@@ -4386,7 +4387,10 @@ async function main(options = {}) {
|
|||||||
repo: repo.repo,
|
repo: repo.repo,
|
||||||
state: 'open',
|
state: 'open',
|
||||||
per_page: 50,
|
per_page: 50,
|
||||||
|
page: Number.isFinite(page) && page > 0 ? page : 1,
|
||||||
});
|
});
|
||||||
|
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
||||||
|
const hasMore = /rel="next"/.test(link);
|
||||||
const issues = (Array.isArray(list?.data) ? list.data : [])
|
const issues = (Array.isArray(list?.data) ? list.data : [])
|
||||||
.filter((item) => !item?.pull_request)
|
.filter((item) => !item?.pull_request)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -4407,7 +4411,7 @@ async function main(options = {}) {
|
|||||||
: [],
|
: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({ connected: true, repo, issues });
|
return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list GitHub issues:', error);
|
console.error('Failed to list GitHub issues:', error);
|
||||||
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
|
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
|
||||||
@@ -4518,6 +4522,268 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================= GitHub Pull Request Context APIs =================
|
||||||
|
|
||||||
|
app.get('/api/github/pulls/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||||
|
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error: 'directory is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.json({ connected: true, repo: null, prs: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await octokit.rest.pulls.list({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: 50,
|
||||||
|
page: Number.isFinite(page) && page > 0 ? page : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
||||||
|
const hasMore = /rel="next"/.test(link);
|
||||||
|
|
||||||
|
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
|
||||||
|
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
||||||
|
const headRepo = pr.head?.repo
|
||||||
|
? {
|
||||||
|
owner: pr.head.repo.owner?.login,
|
||||||
|
repo: pr.head.repo.name,
|
||||||
|
url: pr.head.repo.html_url,
|
||||||
|
cloneUrl: pr.head.repo.clone_url,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
url: pr.html_url,
|
||||||
|
state: mergedState,
|
||||||
|
draft: Boolean(pr.draft),
|
||||||
|
base: pr.base?.ref,
|
||||||
|
head: pr.head?.ref,
|
||||||
|
headSha: pr.head?.sha,
|
||||||
|
mergeable: pr.mergeable,
|
||||||
|
mergeableState: pr.mergeable_state,
|
||||||
|
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
||||||
|
headLabel: pr.head?.label,
|
||||||
|
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
||||||
|
? headRepo
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
clearGitHubAuth();
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
console.error('Failed to list GitHub PRs:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/github/pulls/context', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||||
|
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
||||||
|
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
|
||||||
|
if (!directory || !number) {
|
||||||
|
return res.status(400).json({ error: 'directory and number are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.json({ connected: true, repo: null, pr: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
||||||
|
const prData = prResp?.data;
|
||||||
|
if (!prData) {
|
||||||
|
return res.status(404).json({ error: 'PR not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headRepo = prData.head?.repo
|
||||||
|
? {
|
||||||
|
owner: prData.head.repo.owner?.login,
|
||||||
|
repo: prData.head.repo.name,
|
||||||
|
url: prData.head.repo.html_url,
|
||||||
|
cloneUrl: prData.head.repo.clone_url,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
||||||
|
const pr = {
|
||||||
|
number: prData.number,
|
||||||
|
title: prData.title,
|
||||||
|
url: prData.html_url,
|
||||||
|
state: mergedState,
|
||||||
|
draft: Boolean(prData.draft),
|
||||||
|
base: prData.base?.ref,
|
||||||
|
head: prData.head?.ref,
|
||||||
|
headSha: prData.head?.sha,
|
||||||
|
mergeable: prData.mergeable,
|
||||||
|
mergeableState: prData.mergeable_state,
|
||||||
|
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
|
||||||
|
headLabel: prData.head?.label,
|
||||||
|
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
|
||||||
|
body: prData.body || '',
|
||||||
|
createdAt: prData.created_at,
|
||||||
|
updatedAt: prData.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueCommentsResp = await octokit.rest.issues.listComments({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
issue_number: number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
|
||||||
|
id: comment.id,
|
||||||
|
url: comment.html_url,
|
||||||
|
body: comment.body || '',
|
||||||
|
createdAt: comment.created_at,
|
||||||
|
updatedAt: comment.updated_at,
|
||||||
|
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
pull_number: number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
|
||||||
|
id: comment.id,
|
||||||
|
url: comment.html_url,
|
||||||
|
body: comment.body || '',
|
||||||
|
createdAt: comment.created_at,
|
||||||
|
updatedAt: comment.updated_at,
|
||||||
|
path: comment.path,
|
||||||
|
line: typeof comment.line === 'number' ? comment.line : null,
|
||||||
|
position: typeof comment.position === 'number' ? comment.position : null,
|
||||||
|
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filesResp = await octokit.rest.pulls.listFiles({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
pull_number: number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
|
||||||
|
filename: f.filename,
|
||||||
|
status: f.status,
|
||||||
|
additions: f.additions,
|
||||||
|
deletions: f.deletions,
|
||||||
|
changes: f.changes,
|
||||||
|
patch: f.patch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// checks summary (same logic as status endpoint)
|
||||||
|
let checks = null;
|
||||||
|
const sha = prData.head?.sha;
|
||||||
|
if (sha) {
|
||||||
|
try {
|
||||||
|
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
|
||||||
|
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
||||||
|
if (checkRuns.length > 0) {
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
for (const run of checkRuns) {
|
||||||
|
const status = run?.status;
|
||||||
|
const conclusion = run?.conclusion;
|
||||||
|
if (status === 'queued' || status === 'in_progress') {
|
||||||
|
counts.pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!conclusion) {
|
||||||
|
counts.pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||||
|
counts.success += 1;
|
||||||
|
} else {
|
||||||
|
counts.failure += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
checks = { state, total, ...counts };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall back
|
||||||
|
}
|
||||||
|
if (!checks) {
|
||||||
|
try {
|
||||||
|
const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
|
||||||
|
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
statuses.forEach((s) => {
|
||||||
|
if (s.state === 'success') counts.success += 1;
|
||||||
|
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
||||||
|
else if (s.state === 'pending') counts.pending += 1;
|
||||||
|
});
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
checks = { state, total, ...counts };
|
||||||
|
} catch {
|
||||||
|
checks = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff = undefined;
|
||||||
|
if (includeDiff) {
|
||||||
|
const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
pull_number: number,
|
||||||
|
headers: { accept: 'application/vnd.github.v3.diff' },
|
||||||
|
});
|
||||||
|
diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
repo,
|
||||||
|
pr,
|
||||||
|
issueComments,
|
||||||
|
reviewComments,
|
||||||
|
files,
|
||||||
|
...(diff ? { diff } : {}),
|
||||||
|
checks,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
clearGitHubAuth();
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
console.error('Failed to load GitHub PR context:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/provider/:providerId/source', async (req, res) => {
|
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { providerId } = req.params;
|
const { providerId } = req.params;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
GitHubIssueCommentsResult,
|
GitHubIssueCommentsResult,
|
||||||
GitHubIssueGetResult,
|
GitHubIssueGetResult,
|
||||||
GitHubIssuesListResult,
|
GitHubIssuesListResult,
|
||||||
|
GitHubPullRequestContextResult,
|
||||||
|
GitHubPullRequestsListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -125,9 +127,38 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
|
|||||||
return body;
|
return body;
|
||||||
},
|
},
|
||||||
|
|
||||||
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
|
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
|
||||||
|
const page = options?.page ?? 1;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/github/issues/list?directory=${encodeURIComponent(directory)}`,
|
`/api/github/pulls/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||||
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const body = await jsonOrNull<GitHubPullRequestsListResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error(body?.error || response.statusText || 'Failed to load pull requests');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult> {
|
||||||
|
const url = new URL('/api/github/pulls/context', window.location.origin);
|
||||||
|
url.searchParams.set('directory', directory);
|
||||||
|
url.searchParams.set('number', String(number));
|
||||||
|
if (options?.includeDiff) {
|
||||||
|
url.searchParams.set('diff', '1');
|
||||||
|
}
|
||||||
|
const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||||
|
const body = await jsonOrNull<GitHubPullRequestContextResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error(body?.error || response.statusText || 'Failed to load pull request context');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
|
||||||
|
const page = options?.page ?? 1;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/github/issues/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
);
|
);
|
||||||
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
|
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
|
||||||
|
|||||||
Reference in New Issue
Block a user