feat: add GitHub issue picker and API endpoints
Add GitHubIssuePickerDialog UI for selecting issues Enable new session from GitHub issue from session sidebar Implement GitHub issues/list/get/comments APIs across desktop, web, and VS Code
This commit is contained in:
@@ -145,14 +145,16 @@ Implemented code pointers
|
|||||||
|
|
||||||
## Feature B: Start Session From GitHub Issue
|
## Feature B: Start Session From GitHub Issue
|
||||||
|
|
||||||
|
Status: implemented.
|
||||||
|
|
||||||
### Intent
|
### Intent
|
||||||
Create a new session seeded with issue context, without polluting chat with large issue bodies/comments.
|
Create a new session seeded with issue context, without polluting chat with large issue bodies/comments.
|
||||||
|
|
||||||
### Entry Point
|
### Entry Point
|
||||||
Session kebab menu in `packages/ui/src/components/session/SessionSidebar.tsx`.
|
Project header menu in `packages/ui/src/components/session/SessionSidebar.tsx`.
|
||||||
|
|
||||||
Add new item:
|
Add new item:
|
||||||
- “New session from GitHub issue…”
|
- “New session from GitHub issue”
|
||||||
|
|
||||||
### Modal UI
|
### Modal UI
|
||||||
Issue picker modal:
|
Issue picker modal:
|
||||||
@@ -163,6 +165,10 @@ Issue picker modal:
|
|||||||
- `#123` or `123`
|
- `#123` or `123`
|
||||||
- checkbox: “Create in worktree”
|
- checkbox: “Create in worktree”
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
- modal layout matches Timeline dialog styling/patterns
|
||||||
|
- “Open Repo” + per-issue “Open in GitHub” use `<a href=... target="_blank">` (desktop webview safe)
|
||||||
|
|
||||||
### Worktree option
|
### Worktree option
|
||||||
If enabled:
|
If enabled:
|
||||||
- create a worktree session (reuse `createWorktreeSessionForBranch`)
|
- create a worktree session (reuse `createWorktreeSessionForBranch`)
|
||||||
@@ -177,7 +183,7 @@ If disabled:
|
|||||||
### Session Bootstrap (message)
|
### Session Bootstrap (message)
|
||||||
Send a single user message with:
|
Send a single user message with:
|
||||||
1) Visible text part: concise prompt, e.g.
|
1) Visible text part: concise prompt, e.g.
|
||||||
- “Review the issue, clarify requirements, propose plan, then implement.”
|
- “Review the issue; summarize requirements + unknowns; ask clarifying questions; gather needed code context; propose plan + next actions; do not implement until user confirms.”
|
||||||
2) Hidden synthetic parts: issue payload
|
2) Hidden synthetic parts: issue payload
|
||||||
- issue title/body
|
- issue title/body
|
||||||
- labels, assignees, author
|
- labels, assignees, author
|
||||||
@@ -192,6 +198,22 @@ Do not invent a new hidden-context mechanism.
|
|||||||
- Get issue by number
|
- Get issue by number
|
||||||
- List issue comments
|
- List issue comments
|
||||||
|
|
||||||
|
Implemented code pointers
|
||||||
|
- UI modal: `packages/ui/src/components/session/GitHubIssuePickerDialog.tsx`
|
||||||
|
- Shared sendMessage synthetic parts: `packages/ui/src/lib/opencode/client.ts`
|
||||||
|
- Web server endpoints:
|
||||||
|
- `GET /api/github/issues/list`
|
||||||
|
- `GET /api/github/issues/get`
|
||||||
|
- `GET /api/github/issues/comments`
|
||||||
|
- Desktop Tauri commands:
|
||||||
|
- `github_issues_list`
|
||||||
|
- `github_issue_get`
|
||||||
|
- `github_issue_comments`
|
||||||
|
- VS Code bridge handlers:
|
||||||
|
- `api:github/issues:list`
|
||||||
|
- `api:github/issues:get`
|
||||||
|
- `api:github/issues:comments`
|
||||||
|
|
||||||
## Feature C: Start Session From GitHub PR (with worktree checkout)
|
## Feature C: Start Session From GitHub PR (with worktree checkout)
|
||||||
|
|
||||||
### Intent
|
### Intent
|
||||||
|
|||||||
@@ -84,6 +84,86 @@ pub struct GitHubPullRequestReadyResult {
|
|||||||
ready: bool,
|
ready: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubIssueLabel {
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubIssueSummary {
|
||||||
|
number: u64,
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
state: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author: Option<GitHubUserSummary>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
labels: Option<Vec<GitHubIssueLabel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubIssue {
|
||||||
|
#[serde(flatten)]
|
||||||
|
summary: GitHubIssueSummary,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
assignees: Option<Vec<GitHubUserSummary>>,
|
||||||
|
#[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 GitHubIssueComment {
|
||||||
|
id: u64,
|
||||||
|
url: String,
|
||||||
|
body: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author: Option<GitHubUserSummary>,
|
||||||
|
#[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 GitHubIssuesListResult {
|
||||||
|
connected: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<GitHubRepoRef>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
issues: Option<Vec<GitHubIssueSummary>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubIssueGetResult {
|
||||||
|
connected: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<GitHubRepoRef>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
issue: Option<GitHubIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitHubIssueCommentsResult {
|
||||||
|
connected: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
repo: Option<GitHubRepoRef>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
comments: Option<Vec<GitHubIssueComment>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GitHubUserSummary {
|
pub struct GitHubUserSummary {
|
||||||
@@ -205,6 +285,72 @@ struct ApiUserResponse {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IssueUser {
|
||||||
|
login: String,
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IssueLabel {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IssueListItem {
|
||||||
|
number: u64,
|
||||||
|
title: String,
|
||||||
|
html_url: String,
|
||||||
|
state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Option<IssueUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
labels: Vec<IssueLabel>,
|
||||||
|
#[serde(default)]
|
||||||
|
pull_request: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IssueDetailsResponse {
|
||||||
|
number: u64,
|
||||||
|
title: String,
|
||||||
|
html_url: String,
|
||||||
|
state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Option<IssueUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
labels: Vec<IssueLabel>,
|
||||||
|
#[serde(default)]
|
||||||
|
assignees: Vec<IssueUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
updated_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pull_request: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IssueCommentResponse {
|
||||||
|
id: u64,
|
||||||
|
html_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Option<IssueUser>,
|
||||||
|
#[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,
|
||||||
@@ -591,6 +737,27 @@ async fn fetch_me(access_token: &str) -> Result<GitHubUserSummary, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_issue_user(user: &IssueUser) -> GitHubUserSummary {
|
||||||
|
GitHubUserSummary {
|
||||||
|
login: user.login.clone(),
|
||||||
|
id: user.id,
|
||||||
|
avatar_url: user.avatar_url.clone(),
|
||||||
|
name: None,
|
||||||
|
email: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_issue_labels(labels: Vec<IssueLabel>) -> Vec<GitHubIssueLabel> {
|
||||||
|
labels
|
||||||
|
.into_iter()
|
||||||
|
.filter(|l| !l.name.trim().is_empty())
|
||||||
|
.map(|l| GitHubIssueLabel {
|
||||||
|
name: l.name,
|
||||||
|
color: l.color,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn github_auth_status(
|
pub async fn github_auth_status(
|
||||||
_state: State<'_, DesktopRuntime>,
|
_state: State<'_, DesktopRuntime>,
|
||||||
@@ -1207,3 +1374,246 @@ pub async fn github_pr_ready(
|
|||||||
|
|
||||||
Ok(GitHubPullRequestReadyResult { ready: true })
|
Ok(GitHubPullRequestReadyResult { ready: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn github_issues_list(
|
||||||
|
directory: String,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<GitHubIssuesListResult, 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(GitHubIssuesListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issues: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if stored.access_token.trim().is_empty() {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssuesListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issues: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = resolve_repo_from_directory(&directory).await;
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(GitHubIssuesListResult {
|
||||||
|
connected: true,
|
||||||
|
repo: None,
|
||||||
|
issues: Some(vec![]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/issues?state=open&per_page=50",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo
|
||||||
|
);
|
||||||
|
|
||||||
|
let list = github_get_json::<Vec<IssueListItem>>(&url, &stored.access_token).await;
|
||||||
|
let list = match list {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) if err == "unauthorized" => {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssuesListResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issues: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
let issues = list
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.pull_request.is_none())
|
||||||
|
.map(|item| GitHubIssueSummary {
|
||||||
|
number: item.number,
|
||||||
|
title: item.title,
|
||||||
|
url: item.html_url,
|
||||||
|
state: item.state,
|
||||||
|
author: item.user.as_ref().map(map_issue_user),
|
||||||
|
labels: Some(map_issue_labels(item.labels)),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(GitHubIssuesListResult {
|
||||||
|
connected: true,
|
||||||
|
repo: Some(repo),
|
||||||
|
issues: Some(issues),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn github_issue_get(
|
||||||
|
directory: String,
|
||||||
|
number: u64,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<GitHubIssueGetResult, 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(GitHubIssueGetResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issue: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if stored.access_token.trim().is_empty() {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssueGetResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issue: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = resolve_repo_from_directory(&directory).await;
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(GitHubIssueGetResult {
|
||||||
|
connected: true,
|
||||||
|
repo: None,
|
||||||
|
issue: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/issues/{}",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
|
||||||
|
);
|
||||||
|
|
||||||
|
let issue = github_get_json::<IssueDetailsResponse>(&url, &stored.access_token).await;
|
||||||
|
let issue = match issue {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) if err == "unauthorized" => {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssueGetResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
issue: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
if issue.pull_request.is_some() {
|
||||||
|
return Err("Not a GitHub issue".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = GitHubIssueSummary {
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
url: issue.html_url,
|
||||||
|
state: issue.state,
|
||||||
|
author: issue.user.as_ref().map(map_issue_user),
|
||||||
|
labels: Some(map_issue_labels(issue.labels)),
|
||||||
|
};
|
||||||
|
let assignees = issue
|
||||||
|
.assignees
|
||||||
|
.iter()
|
||||||
|
.map(map_issue_user)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(GitHubIssueGetResult {
|
||||||
|
connected: true,
|
||||||
|
repo: Some(repo),
|
||||||
|
issue: Some(GitHubIssue {
|
||||||
|
summary,
|
||||||
|
body: issue.body,
|
||||||
|
assignees: Some(assignees),
|
||||||
|
created_at: issue.created_at,
|
||||||
|
updated_at: issue.updated_at,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn github_issue_comments(
|
||||||
|
directory: String,
|
||||||
|
number: u64,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<GitHubIssueCommentsResult, 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(GitHubIssueCommentsResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
comments: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if stored.access_token.trim().is_empty() {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssueCommentsResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
comments: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = resolve_repo_from_directory(&directory).await;
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(GitHubIssueCommentsResult {
|
||||||
|
connected: true,
|
||||||
|
repo: None,
|
||||||
|
comments: Some(vec![]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/issues/{}/comments?per_page=100",
|
||||||
|
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
|
||||||
|
);
|
||||||
|
|
||||||
|
let comments = github_get_json::<Vec<IssueCommentResponse>>(&url, &stored.access_token).await;
|
||||||
|
let comments = match comments {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) if err == "unauthorized" => {
|
||||||
|
let _ = clear_auth_file().await;
|
||||||
|
return Ok(GitHubIssueCommentsResult {
|
||||||
|
connected: false,
|
||||||
|
repo: None,
|
||||||
|
comments: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mapped = 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<_>>();
|
||||||
|
|
||||||
|
Ok(GitHubIssueCommentsResult {
|
||||||
|
connected: true,
|
||||||
|
repo: Some(repo),
|
||||||
|
comments: Some(mapped),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,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_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;
|
||||||
@@ -906,6 +907,9 @@ fn main() {
|
|||||||
github_pr_create,
|
github_pr_create,
|
||||||
github_pr_merge,
|
github_pr_merge,
|
||||||
github_pr_ready,
|
github_pr_ready,
|
||||||
|
github_issues_list,
|
||||||
|
github_issue_get,
|
||||||
|
github_issue_comments,
|
||||||
])
|
])
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
GitHubAPI,
|
GitHubAPI,
|
||||||
GitHubAuthStatus,
|
GitHubAuthStatus,
|
||||||
|
GitHubIssueCommentsResult,
|
||||||
|
GitHubIssueGetResult,
|
||||||
|
GitHubIssuesListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -59,4 +62,19 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
|
|||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory }, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubIssueGetResult>('github_issue_get', { directory, number }, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
||||||
if (desktop?.openExternal) {
|
if (desktop?.openExternal) {
|
||||||
try {
|
try {
|
||||||
await desktop.openExternal(url);
|
const result = await desktop.openExternal(url);
|
||||||
return;
|
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
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,
|
||||||
|
RiGithubLine,
|
||||||
|
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 { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
||||||
|
import { createBranch } from '@/lib/gitApi';
|
||||||
|
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } from '@/lib/api/types';
|
||||||
|
|
||||||
|
const parseIssueNumber = (value: string): number | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const urlMatch = trimmed.match(/\/issues\/(\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 sanitizeSlug = (value: string): string => {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^[-_]+|[-_]+$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildIssueContextText = (args: {
|
||||||
|
repo: GitHubIssuesListResult['repo'] | undefined;
|
||||||
|
issue: GitHubIssue;
|
||||||
|
comments: GitHubIssueComment[];
|
||||||
|
}) => {
|
||||||
|
const payload = {
|
||||||
|
repo: args.repo ?? null,
|
||||||
|
issue: args.issue,
|
||||||
|
comments: args.comments,
|
||||||
|
};
|
||||||
|
return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GitHubIssuePickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { github } = useRuntimeAPIs();
|
||||||
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||||
|
|
||||||
|
const projectDirectory = activeProject?.path ?? null;
|
||||||
|
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
|
||||||
|
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||||
|
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
|
||||||
|
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = 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?.issuesList) {
|
||||||
|
setResult(null);
|
||||||
|
setError('GitHub runtime API unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await github.issuesList(projectDirectory);
|
||||||
|
setResult(next);
|
||||||
|
if (next.connected === false) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [github, projectDirectory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery('');
|
||||||
|
setCreateInWorktree(false);
|
||||||
|
setStartingIssueNumber(null);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refresh();
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]);
|
||||||
|
const connected = Boolean(result?.connected);
|
||||||
|
const repoUrl = result?.repo?.url ?? null;
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return issues;
|
||||||
|
return issues.filter((issue) => {
|
||||||
|
if (String(issue.number) === q.replace(/^#/, '')) return true;
|
||||||
|
return issue.title.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [issues, query]);
|
||||||
|
|
||||||
|
const directNumber = React.useMemo(() => parseIssueNumber(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 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) => {
|
||||||
|
if (!projectDirectory) {
|
||||||
|
toast.error('No active project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!github?.issueGet || !github?.issueComments) {
|
||||||
|
toast.error('GitHub runtime API unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startingIssueNumber) return;
|
||||||
|
setStartingIssueNumber(issueNumber);
|
||||||
|
try {
|
||||||
|
const issueRes = await github.issueGet(projectDirectory, issueNumber);
|
||||||
|
if (issueRes.connected === false) {
|
||||||
|
toast.error('GitHub not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!issueRes.repo) {
|
||||||
|
toast.error('Repo not resolvable', {
|
||||||
|
description: 'origin remote must be a GitHub URL',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const issue = issueRes.issue;
|
||||||
|
if (!issue) {
|
||||||
|
toast.error('Issue not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
|
||||||
|
if (commentsRes.connected === false) {
|
||||||
|
toast.error('GitHub not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comments = commentsRes.comments ?? [];
|
||||||
|
|
||||||
|
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
|
||||||
|
|
||||||
|
const sessionId = await (async () => {
|
||||||
|
if (createInWorktree) {
|
||||||
|
const branchName = await buildUniqueIssueBranchName(issue);
|
||||||
|
const created = await createWorktreeSessionForBranch(projectDirectory, branchName);
|
||||||
|
if (!created?.id) {
|
||||||
|
throw new Error('Failed to create worktree session');
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
|
||||||
|
if (!session?.id) {
|
||||||
|
throw new Error('Failed to create session');
|
||||||
|
}
|
||||||
|
return session.id;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Ensure worktree-based sessions also get the issue title.
|
||||||
|
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal immediately after session exists (don't wait for message send).
|
||||||
|
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 promptText =
|
||||||
|
'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 contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
|
||||||
|
|
||||||
|
void opencodeClient.sendMessage({
|
||||||
|
id: sessionId,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
agent: agentName,
|
||||||
|
variant,
|
||||||
|
text: promptText,
|
||||||
|
additionalParts: [{ text: contextText, synthetic: true }],
|
||||||
|
}).catch((e) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to send issue context', {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Session created from issue');
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error('Failed to start session', { description: message });
|
||||||
|
} finally {
|
||||||
|
setStartingIssueNumber(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
buildUniqueIssueBranchName,
|
||||||
|
createInWorktree,
|
||||||
|
github,
|
||||||
|
onOpenChange,
|
||||||
|
projectDirectory,
|
||||||
|
resolveDefaultAgentName,
|
||||||
|
resolveDefaultModelSelection,
|
||||||
|
resolveDefaultVariant,
|
||||||
|
startingIssueNumber,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<RiGithubLine className="h-5 w-5" />
|
||||||
|
New Session From GitHub Issue
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Seeds a new session with hidden issue context (title/body/labels/comments).
|
||||||
|
</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 issue 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 issues...
|
||||||
|
</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',
|
||||||
|
startingIssueNumber === 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 issue #{directNumber}
|
||||||
|
</p>
|
||||||
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||||
|
{startingIssueNumber === 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 issues found' : 'No open issues found'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filtered.map((issue) => (
|
||||||
|
<div
|
||||||
|
key={issue.number}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
||||||
|
startingIssueNumber === issue.number && 'bg-muted/30'
|
||||||
|
)}
|
||||||
|
onClick={() => void startSession(issue.number)}
|
||||||
|
>
|
||||||
|
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
|
||||||
|
#{issue.number}
|
||||||
|
</span>
|
||||||
|
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||||
|
{issue.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||||
|
{startingIssueNumber === issue.number ? (
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={issue.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>
|
||||||
|
))}
|
||||||
|
</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 in worktree</span>
|
||||||
|
<span className="typography-meta text-muted-foreground/70">(issue-<number>-<slug>)</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(startingIssueNumber)}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
RiGitRepositoryLine,
|
RiGitRepositoryLine,
|
||||||
RiLinkUnlinkM,
|
RiLinkUnlinkM,
|
||||||
|
|
||||||
|
RiGithubLine,
|
||||||
|
|
||||||
RiMore2Line,
|
RiMore2Line,
|
||||||
RiPencilAiLine,
|
RiPencilAiLine,
|
||||||
RiShare2Line,
|
RiShare2Line,
|
||||||
@@ -62,6 +64,7 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
|
|||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
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';
|
||||||
|
|
||||||
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';
|
||||||
@@ -139,6 +142,7 @@ interface SortableProjectItemProps {
|
|||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
onOpenBranchPicker?: () => void;
|
onOpenBranchPicker?: () => void;
|
||||||
|
onNewSessionFromGitHubIssue?: () => void;
|
||||||
onOpenMultiRunLauncher: () => void;
|
onOpenMultiRunLauncher: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||||
@@ -163,6 +167,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
onOpenBranchPicker,
|
onOpenBranchPicker,
|
||||||
|
onNewSessionFromGitHubIssue,
|
||||||
onOpenMultiRunLauncher,
|
onOpenMultiRunLauncher,
|
||||||
onClose,
|
onClose,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
@@ -273,6 +278,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
Browse Branches
|
Browse Branches
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
|
||||||
|
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
|
||||||
|
<RiGithubLine className="mr-1.5 h-4 w-4" />
|
||||||
|
New session from GitHub issue
|
||||||
|
</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" />
|
||||||
@@ -399,6 +410,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||||
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 [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);
|
||||||
@@ -1631,6 +1643,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
createWorktreeSession();
|
createWorktreeSession();
|
||||||
}}
|
}}
|
||||||
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||||
|
onNewSessionFromGitHubIssue={() => {
|
||||||
|
if (projectKey !== activeProjectId) {
|
||||||
|
setActiveProject(projectKey);
|
||||||
|
}
|
||||||
|
setActiveMainTab('chat');
|
||||||
|
if (mobileVariant) {
|
||||||
|
setSessionSwitcherOpen(false);
|
||||||
|
}
|
||||||
|
setIssuePickerOpen(true);
|
||||||
|
}}
|
||||||
onOpenMultiRunLauncher={() => {
|
onOpenMultiRunLauncher={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProject(projectKey);
|
||||||
@@ -1672,6 +1694,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
projects={normalizedProjects}
|
projects={normalizedProjects}
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GitHubIssuePickerDialog
|
||||||
|
open={issuePickerOpen}
|
||||||
|
onOpenChange={setIssuePickerOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ const openExternal = async (url: string) => {
|
|||||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
||||||
if (desktop?.openExternal) {
|
if (desktop?.openExternal) {
|
||||||
try {
|
try {
|
||||||
await desktop.openExternal(url);
|
const result = await desktop.openExternal(url);
|
||||||
return;
|
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,6 +560,54 @@ export type GitHubPullRequestMergeResult = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GitHubIssueLabel = {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssueSummary = {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
labels?: GitHubIssueLabel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssue = GitHubIssueSummary & {
|
||||||
|
body?: string;
|
||||||
|
assignees?: GitHubUserSummary[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssueComment = {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
author?: GitHubUserSummary | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssuesListResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
issues?: GitHubIssueSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssueGetResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
issue?: GitHubIssue | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubIssueCommentsResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
comments?: GitHubIssueComment[];
|
||||||
|
};
|
||||||
|
|
||||||
export type GitHubAuthStatus = {
|
export type GitHubAuthStatus = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
user?: GitHubUserSummary | null;
|
user?: GitHubUserSummary | null;
|
||||||
@@ -591,6 +639,10 @@ export interface GitHubAPI {
|
|||||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||||
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>;
|
||||||
|
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
|
||||||
|
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeAPIs {
|
export interface RuntimeAPIs {
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ class OpencodeService {
|
|||||||
modelID: string;
|
modelID: string;
|
||||||
text: string;
|
text: string;
|
||||||
prefaceText?: string;
|
prefaceText?: string;
|
||||||
|
prefaceTextSynthetic?: boolean;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
@@ -609,6 +610,7 @@ class OpencodeService {
|
|||||||
/** Additional text/file parts to include (for batch sending queued messages) */
|
/** Additional text/file parts to include (for batch sending queued messages) */
|
||||||
additionalParts?: Array<{
|
additionalParts?: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
|
synthetic?: boolean;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
type: 'file';
|
type: 'file';
|
||||||
mime: string;
|
mime: string;
|
||||||
@@ -630,7 +632,8 @@ class OpencodeService {
|
|||||||
if (params.prefaceText && params.prefaceText.trim()) {
|
if (params.prefaceText && params.prefaceText.trim()) {
|
||||||
parts.push({
|
parts.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: params.prefaceText
|
text: params.prefaceText,
|
||||||
|
synthetic: params.prefaceTextSynthetic !== false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +666,8 @@ class OpencodeService {
|
|||||||
if (additional.text && additional.text.trim()) {
|
if (additional.text && additional.text.trim()) {
|
||||||
parts.push({
|
parts.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: additional.text
|
text: additional.text,
|
||||||
|
...(additional.synthetic ? { synthetic: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (additional.files && additional.files.length > 0) {
|
if (additional.files && additional.files.length > 0) {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import {
|
|||||||
mergePullRequest,
|
mergePullRequest,
|
||||||
} from './githubPr';
|
} from './githubPr';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIssue,
|
||||||
|
listIssueComments,
|
||||||
|
listIssues,
|
||||||
|
} from './githubIssues';
|
||||||
|
|
||||||
export interface BridgeRequest {
|
export interface BridgeRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -1171,6 +1177,77 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:github/issues: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');
|
||||||
|
if (!directory) {
|
||||||
|
return { id, type, success: false, error: 'directory is required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await listIssues(stored.accessToken, directory);
|
||||||
|
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/issues:get': {
|
||||||
|
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;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return { id, type, success: false, error: 'directory and number are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await getIssue(stored.accessToken, directory, number);
|
||||||
|
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/issues:comments': {
|
||||||
|
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;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return { id, type, success: false, error: 'directory and number are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await listIssueComments(stored.accessToken, directory, number);
|
||||||
|
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 } };
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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 GitHubIssuesListResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
issues?: Array<{
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||||
|
labels?: Array<{ name: string; color?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubIssueGetResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
issue?: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||||
|
labels?: Array<{ name: string; color?: string }>;
|
||||||
|
body?: string;
|
||||||
|
assignees?: Array<{ login: string; id?: number; avatarUrl?: string; name?: string; email?: string }>;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubIssueCommentsResult = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
comments?: Array<{
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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) => {
|
||||||
|
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 mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => {
|
||||||
|
const list = Array.isArray(raw) ? raw : [];
|
||||||
|
return list
|
||||||
|
.map((item) => {
|
||||||
|
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||||
|
const name = readString(rec?.name);
|
||||||
|
if (!name) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
color: readString(rec?.color) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ name: string; color?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listIssues = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
): Promise<GitHubIssuesListResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, issues: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
||||||
|
url.searchParams.set('state', 'open');
|
||||||
|
url.searchParams.set('per_page', '50');
|
||||||
|
|
||||||
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await jsonOrNull<unknown[]>(resp);
|
||||||
|
if (!resp.ok || !Array.isArray(json)) {
|
||||||
|
throw new Error('Failed to load issues');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = json
|
||||||
|
.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
if (!rec || rec.pull_request) return null;
|
||||||
|
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||||
|
if (!number) return null;
|
||||||
|
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||||
|
return {
|
||||||
|
number,
|
||||||
|
title: readString(rec.title) || '',
|
||||||
|
url: readString(rec.html_url) || '',
|
||||||
|
state,
|
||||||
|
author: mapUser(rec.user),
|
||||||
|
labels: mapLabels(rec.labels),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||||
|
|
||||||
|
return { connected: true, repo, issues: issues || [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIssue = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
number: number,
|
||||||
|
): Promise<GitHubIssueGetResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, issue: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}`, accessToken);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
const json = await jsonOrNull<JsonRecord>(resp);
|
||||||
|
if (!resp.ok || !json) {
|
||||||
|
throw new Error('Failed to load issue');
|
||||||
|
}
|
||||||
|
if (json.pull_request) {
|
||||||
|
throw new Error('Not a GitHub issue');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readString(json.state) === 'closed' ? 'closed' : 'open';
|
||||||
|
const assigneesRaw = Array.isArray(json.assignees) ? json.assignees : [];
|
||||||
|
const assignees = assigneesRaw.map(mapUser).filter(Boolean) as Array<NonNullable<ReturnType<typeof mapUser>>>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
repo,
|
||||||
|
issue: {
|
||||||
|
number: typeof json.number === 'number' ? json.number : number,
|
||||||
|
title: readString(json.title) || '',
|
||||||
|
url: readString(json.html_url) || '',
|
||||||
|
state,
|
||||||
|
author: mapUser(json.user),
|
||||||
|
labels: mapLabels(json.labels),
|
||||||
|
body: readString(json.body) || '',
|
||||||
|
assignees,
|
||||||
|
createdAt: readString(json.created_at) || undefined,
|
||||||
|
updatedAt: readString(json.updated_at) || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listIssueComments = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
number: number,
|
||||||
|
): Promise<GitHubIssueCommentsResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, comments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments`);
|
||||||
|
url.searchParams.set('per_page', '100');
|
||||||
|
|
||||||
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
const json = await jsonOrNull<unknown[]>(resp);
|
||||||
|
if (!resp.ok || !Array.isArray(json)) {
|
||||||
|
throw new Error('Failed to load issue comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = json
|
||||||
|
.map((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
if (!rec) return 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 GitHubIssueCommentsResult['comments'];
|
||||||
|
|
||||||
|
return { connected: true, repo, comments: comments || [] };
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
GitHubAPI,
|
GitHubAPI,
|
||||||
GitHubAuthStatus,
|
GitHubAuthStatus,
|
||||||
|
GitHubIssueCommentsResult,
|
||||||
|
GitHubIssueGetResult,
|
||||||
|
GitHubIssuesListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -31,4 +34,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
|||||||
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
||||||
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) =>
|
||||||
|
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory }),
|
||||||
|
issueGet: async (directory: string, number: number) =>
|
||||||
|
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number }),
|
||||||
|
issueComments: async (directory: string, number: number) =>
|
||||||
|
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4360,6 +4360,164 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================= GitHub Issue APIs =================
|
||||||
|
|
||||||
|
app.get('/api/github/issues/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||||
|
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, issues: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await octokit.rest.issues.listForRepo({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: 50,
|
||||||
|
});
|
||||||
|
const issues = (Array.isArray(list?.data) ? list.data : [])
|
||||||
|
.filter((item) => !item?.pull_request)
|
||||||
|
.map((item) => ({
|
||||||
|
number: item.number,
|
||||||
|
title: item.title,
|
||||||
|
url: item.html_url,
|
||||||
|
state: item.state === 'closed' ? 'closed' : 'open',
|
||||||
|
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
|
||||||
|
labels: Array.isArray(item.labels)
|
||||||
|
? item.labels
|
||||||
|
.map((label) => {
|
||||||
|
if (typeof label === 'string') return null;
|
||||||
|
const name = typeof label?.name === 'string' ? label.name : '';
|
||||||
|
if (!name) return null;
|
||||||
|
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({ connected: true, repo, issues });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list GitHub issues:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/github/issues/get', 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;
|
||||||
|
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, issue: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
|
||||||
|
const issue = result?.data;
|
||||||
|
if (!issue || issue.pull_request) {
|
||||||
|
return res.status(400).json({ error: 'Not a GitHub issue' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
repo,
|
||||||
|
issue: {
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
url: issue.html_url,
|
||||||
|
state: issue.state === 'closed' ? 'closed' : 'open',
|
||||||
|
body: issue.body || '',
|
||||||
|
createdAt: issue.created_at,
|
||||||
|
updatedAt: issue.updated_at,
|
||||||
|
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
|
||||||
|
assignees: Array.isArray(issue.assignees)
|
||||||
|
? issue.assignees
|
||||||
|
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
labels: Array.isArray(issue.labels)
|
||||||
|
? issue.labels
|
||||||
|
.map((label) => {
|
||||||
|
if (typeof label === 'string') return null;
|
||||||
|
const name = typeof label?.name === 'string' ? label.name : '';
|
||||||
|
if (!name) return null;
|
||||||
|
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch GitHub issue:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/github/issues/comments', 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;
|
||||||
|
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, comments: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await octokit.rest.issues.listComments({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
issue_number: number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const comments = (Array.isArray(result?.data) ? result.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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({ connected: true, repo, comments });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch GitHub issue comments:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
GitHubAPI,
|
GitHubAPI,
|
||||||
GitHubAuthStatus,
|
GitHubAuthStatus,
|
||||||
|
GitHubIssueCommentsResult,
|
||||||
|
GitHubIssueGetResult,
|
||||||
|
GitHubIssuesListResult,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubPullRequestCreateInput,
|
GitHubPullRequestCreateInput,
|
||||||
GitHubPullRequestMergeInput,
|
GitHubPullRequestMergeInput,
|
||||||
@@ -121,4 +124,40 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
|
|||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/github/issues/list?directory=${encodeURIComponent(directory)}`,
|
||||||
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to load issues');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/github/issues/get?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
|
||||||
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const payload = await jsonOrNull<GitHubIssueGetResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to load issue');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/github/issues/comments?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
|
||||||
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const payload = await jsonOrNull<GitHubIssueCommentsResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user