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
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Intent
|
||||
Create a new session seeded with issue context, without polluting chat with large issue bodies/comments.
|
||||
|
||||
### 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:
|
||||
- “New session from GitHub issue…”
|
||||
- “New session from GitHub issue”
|
||||
|
||||
### Modal UI
|
||||
Issue picker modal:
|
||||
@@ -163,6 +165,10 @@ Issue picker modal:
|
||||
- `#123` or `123`
|
||||
- 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
|
||||
If enabled:
|
||||
- create a worktree session (reuse `createWorktreeSessionForBranch`)
|
||||
@@ -177,7 +183,7 @@ If disabled:
|
||||
### Session Bootstrap (message)
|
||||
Send a single user message with:
|
||||
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
|
||||
- issue title/body
|
||||
- labels, assignees, author
|
||||
@@ -192,6 +198,22 @@ Do not invent a new hidden-context mechanism.
|
||||
- Get issue by number
|
||||
- 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)
|
||||
|
||||
### Intent
|
||||
|
||||
@@ -84,6 +84,86 @@ pub struct GitHubPullRequestReadyResult {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitHubUserSummary {
|
||||
@@ -205,6 +285,72 @@ struct ApiUserResponse {
|
||||
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)]
|
||||
struct PrListItem {
|
||||
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]
|
||||
pub async fn github_auth_status(
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
@@ -1207,3 +1374,246 @@ pub async fn github_pr_ready(
|
||||
|
||||
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::{
|
||||
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,
|
||||
};
|
||||
use commands::notifications::desktop_notify;
|
||||
@@ -906,6 +907,9 @@ fn main() {
|
||||
github_pr_create,
|
||||
github_pr_merge,
|
||||
github_pr_ready,
|
||||
github_issues_list,
|
||||
github_issue_get,
|
||||
github_issue_comments,
|
||||
])
|
||||
.on_menu_event(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestCreateInput,
|
||||
GitHubPullRequestMergeInput,
|
||||
@@ -59,4 +62,19 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
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;
|
||||
if (desktop?.openExternal) {
|
||||
try {
|
||||
await desktop.openExternal(url);
|
||||
return;
|
||||
const result = await desktop.openExternal(url);
|
||||
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 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,
|
||||
RiLinkUnlinkM,
|
||||
|
||||
RiGithubLine,
|
||||
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
RiShare2Line,
|
||||
@@ -62,6 +64,7 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||
@@ -139,6 +142,7 @@ interface SortableProjectItemProps {
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onOpenBranchPicker?: () => void;
|
||||
onNewSessionFromGitHubIssue?: () => void;
|
||||
onOpenMultiRunLauncher: () => void;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
@@ -163,6 +167,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onOpenBranchPicker,
|
||||
onNewSessionFromGitHubIssue,
|
||||
onOpenMultiRunLauncher,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
@@ -273,6 +278,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
Browse Branches
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
|
||||
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
|
||||
<RiGithubLine className="mr-1.5 h-4 w-4" />
|
||||
New session from GitHub issue
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && (
|
||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||
<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 [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||
@@ -1631,6 +1643,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
createWorktreeSession();
|
||||
}}
|
||||
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||
onNewSessionFromGitHubIssue={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
setIssuePickerOpen(true);
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
@@ -1672,6 +1694,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
projects={normalizedProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
/>
|
||||
|
||||
<GitHubIssuePickerDialog
|
||||
open={issuePickerOpen}
|
||||
onOpenChange={setIssuePickerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,8 +52,10 @@ const openExternal = async (url: string) => {
|
||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
||||
if (desktop?.openExternal) {
|
||||
try {
|
||||
await desktop.openExternal(url);
|
||||
return;
|
||||
const result = await desktop.openExternal(url);
|
||||
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
@@ -560,6 +560,54 @@ export type GitHubPullRequestMergeResult = {
|
||||
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 = {
|
||||
connected: boolean;
|
||||
user?: GitHubUserSummary | null;
|
||||
@@ -591,6 +639,10 @@ export interface GitHubAPI {
|
||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||
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 {
|
||||
|
||||
@@ -598,6 +598,7 @@ class OpencodeService {
|
||||
modelID: string;
|
||||
text: string;
|
||||
prefaceText?: string;
|
||||
prefaceTextSynthetic?: boolean;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
files?: Array<{
|
||||
@@ -609,6 +610,7 @@ class OpencodeService {
|
||||
/** Additional text/file parts to include (for batch sending queued messages) */
|
||||
additionalParts?: Array<{
|
||||
text: string;
|
||||
synthetic?: boolean;
|
||||
files?: Array<{
|
||||
type: 'file';
|
||||
mime: string;
|
||||
@@ -630,7 +632,8 @@ class OpencodeService {
|
||||
if (params.prefaceText && params.prefaceText.trim()) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: params.prefaceText
|
||||
text: params.prefaceText,
|
||||
synthetic: params.prefaceTextSynthetic !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -663,7 +666,8 @@ class OpencodeService {
|
||||
if (additional.text && additional.text.trim()) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: additional.text
|
||||
text: additional.text,
|
||||
...(additional.synthetic ? { synthetic: true } : {}),
|
||||
});
|
||||
}
|
||||
if (additional.files && additional.files.length > 0) {
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
mergePullRequest,
|
||||
} from './githubPr';
|
||||
|
||||
import {
|
||||
getIssue,
|
||||
listIssueComments,
|
||||
listIssues,
|
||||
} from './githubIssues';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: 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': {
|
||||
await ctx?.manager?.restart();
|
||||
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 {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestCreateInput,
|
||||
GitHubPullRequestMergeInput,
|
||||
@@ -31,4 +34,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
||||
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
||||
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
||||
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) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestCreateInput,
|
||||
GitHubPullRequestMergeInput,
|
||||
@@ -121,4 +124,40 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
|
||||
}
|
||||
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