From 57f00be773eee36f3fbbd90683ff90c4821fa4e0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 24 Jan 2026 00:30:34 +0200 Subject: [PATCH] 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 --- docs/github-features-plan.md | 28 +- .../desktop/src-tauri/src/commands/github.rs | 410 +++++++++++++ packages/desktop/src-tauri/src/main.rs | 4 + packages/desktop/src/api/github.ts | 18 + .../sections/openchamber/GitHubSettings.tsx | 6 +- .../session/GitHubIssuePickerDialog.tsx | 550 ++++++++++++++++++ .../src/components/session/SessionSidebar.tsx | 27 + .../views/git/PullRequestSection.tsx | 6 +- packages/ui/src/lib/api/types.ts | 52 ++ packages/ui/src/lib/opencode/client.ts | 8 +- packages/vscode/src/bridge.ts | 77 +++ packages/vscode/src/githubIssues.ts | 230 ++++++++ packages/vscode/webview/api/github.ts | 10 + packages/web/server/index.js | 158 +++++ packages/web/src/api/github.ts | 39 ++ 15 files changed, 1614 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/session/GitHubIssuePickerDialog.tsx create mode 100644 packages/vscode/src/githubIssues.ts diff --git a/docs/github-features-plan.md b/docs/github-features-plan.md index a5118ebb..a9aa6961 100644 --- a/docs/github-features-plan.md +++ b/docs/github-features-plan.md @@ -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 `` (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 diff --git a/packages/desktop/src-tauri/src/commands/github.rs b/packages/desktop/src-tauri/src/commands/github.rs index 54e1a79d..d74d7344 100644 --- a/packages/desktop/src-tauri/src/commands/github.rs +++ b/packages/desktop/src-tauri/src/commands/github.rs @@ -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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + labels: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + assignees: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubIssuesListResult { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + issues: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubIssueGetResult { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + issue: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubIssueCommentsResult { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comments: Option>, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct GitHubUserSummary { @@ -205,6 +285,72 @@ struct ApiUserResponse { email: Option, } +#[derive(Debug, Deserialize)] +struct IssueUser { + login: String, + #[serde(default)] + id: Option, + #[serde(default)] + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct IssueLabel { + name: String, + #[serde(default)] + color: Option, +} + +#[derive(Debug, Deserialize)] +struct IssueListItem { + number: u64, + title: String, + html_url: String, + state: String, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + pull_request: Option, +} + +#[derive(Debug, Deserialize)] +struct IssueDetailsResponse { + number: u64, + title: String, + html_url: String, + state: String, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + assignees: Vec, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + pull_request: Option, +} + +#[derive(Debug, Deserialize)] +struct IssueCommentResponse { + id: u64, + html_url: String, + #[serde(default)] + body: Option, + #[serde(default)] + user: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, +} + #[derive(Debug, Deserialize)] struct PrListItem { number: u64, @@ -591,6 +737,27 @@ async fn fetch_me(access_token: &str) -> Result { }) } +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) -> Vec { + 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 { + 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::>(&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::>(); + + 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 { + 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::(&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::>(); + + 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 { + 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::>(&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::>(); + + Ok(GitHubIssueCommentsResult { + connected: true, + repo: Some(repo), + comments: Some(mapped), + }) +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 875f9cea..c87f4b18 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -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")] diff --git a/packages/desktop/src/api/github.ts b/packages/desktop/src/api/github.ts index 00300c5f..a3c05219 100644 --- a/packages/desktop/src/api/github.ts +++ b/packages/desktop/src/api/github.ts @@ -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('github_pr_ready', payload, { timeout: 20000 }); }, + + async issuesList(directory: string): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_issues_list', { directory }, { timeout: 20000 }); + }, + + async issueGet(directory: string, number: number): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_issue_get', { directory, number }, { timeout: 20000 }); + }, + + async issueComments(directory: string, number: number): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_issue_comments', { directory, number }, { timeout: 20000 }); + }, }); diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index f110017f..91c1e461 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -44,8 +44,10 @@ export const GitHubSettings: React.FC = () => { const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).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 } diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx new file mode 100644 index 00000000..41d3f25d --- /dev/null +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -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(null); + const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(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) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | 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 ( + + + + + + New Session From GitHub Issue + + + Seeds a new session with hidden issue context (title/body/labels/comments). + + + +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {!projectDirectory ? ( +
No active project selected.
+ ) : null} + + {!github ? ( +
GitHub runtime API unavailable.
+ ) : null} + + {isLoading ? ( +
+ + Loading issues... +
+ ) : null} + + {connected === false ? ( +
GitHub not connected.
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && github && connected ? ( +
void startSession(directNumber)} + > + # +

+ Use issue #{directNumber} +

+
+ {startingIssueNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {filtered.length === 0 && !isLoading && connected && github && projectDirectory ? ( +
{query ? 'No issues found' : 'No open issues found'}
+ ) : null} + + {filtered.map((issue) => ( +
void startSession(issue.number)} + > + + #{issue.number} + +

+ {issue.title} +

+ +
+ {startingIssueNumber === issue.number ? ( + + ) : ( + e.stopPropagation()} + aria-label="Open in GitHub" + > + + + )} +
+
+ ))} +
+ +
+

Actions

+
+
setCreateInWorktree((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setCreateInWorktree((v) => !v); + } + }} + > + + Create in worktree + (issue-<number>-<slug>) +
+
+ {repoUrl ? ( + + ) : null} + +
+
+ + + ); +} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index eb5bfbeb..96bb4900 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ onNewSession, onNewWorktreeSession, onOpenBranchPicker, + onNewSessionFromGitHubIssue, onOpenMultiRunLauncher, onClose, sentinelRef, @@ -273,6 +278,12 @@ const SortableProjectItem: React.FC = ({ Browse Branches )} + {isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && ( + + + New session from GitHub issue + + )} {isRepo && !hideDirectoryControls && ( @@ -399,6 +410,7 @@ export const SessionSidebar: React.FC = ({ const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [branchPickerOpen, setBranchPickerOpen] = React.useState(false); + const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [activeDragId, setActiveDragId] = React.useState(null); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); @@ -1631,6 +1643,16 @@ export const SessionSidebar: React.FC = ({ 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 = ({ projects={normalizedProjects} activeProjectId={activeProjectId} /> + +
); }; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index c5aa86ba..57354bd8 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -52,8 +52,10 @@ const openExternal = async (url: string) => { const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).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 } diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 53325117..7c942d0e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; prMerge(payload: GitHubPullRequestMergeInput): Promise; prReady(payload: GitHubPullRequestReadyInput): Promise; + + issuesList(directory: string): Promise; + issueGet(directory: string, number: number): Promise; + issueComments(directory: string, number: number): Promise; } export interface RuntimeAPIs { diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index a65978b2..19c78e9d 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -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) { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 1559a667..3e3d503f 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -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 } }; diff --git a/packages/vscode/src/githubIssues.ts b/packages/vscode/src/githubIssues.ts new file mode 100644 index 00000000..1fa22852 --- /dev/null +++ b/packages/vscode/src/githubIssues.ts @@ -0,0 +1,230 @@ +import { resolveRepoFromDirectory } from './githubPr'; + +const API_BASE = 'https://api.github.com'; + +type JsonRecord = Record; + +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 => { + return fetch(url, { + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'OpenChamber', + ...(init?.headers || {}), + }, + }); +}; + +const jsonOrNull = async (response: Response): Promise => { + 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 => { + 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(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 => { + 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(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>>; + + 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 => { + 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(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 || [] }; +}; diff --git a/packages/vscode/webview/api/github.ts b/packages/vscode/webview/api/github.ts index c8cdfdb2..9771c58d 100644 --- a/packages/vscode/webview/api/github.ts +++ b/packages/vscode/webview/api/github.ts @@ -1,6 +1,9 @@ import type { GitHubAPI, GitHubAuthStatus, + GitHubIssueCommentsResult, + GitHubIssueGetResult, + GitHubIssuesListResult, GitHubPullRequest, GitHubPullRequestCreateInput, GitHubPullRequestMergeInput, @@ -31,4 +34,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({ sendBridgeMessage('api:github/pr:merge', payload), prReady: async (payload: GitHubPullRequestReadyInput) => sendBridgeMessage('api:github/pr:ready', payload), + + issuesList: async (directory: string) => + sendBridgeMessage('api:github/issues:list', { directory }), + issueGet: async (directory: string, number: number) => + sendBridgeMessage('api:github/issues:get', { directory, number }), + issueComments: async (directory: string, number: number) => + sendBridgeMessage('api:github/issues:comments', { directory, number }), }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 90ca28a2..2c8d2807 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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; diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index f18d2968..e163f47e 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -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 { + const response = await fetch( + `/api/github/issues/list?directory=${encodeURIComponent(directory)}`, + { method: 'GET', headers: { Accept: 'application/json' } } + ); + const payload = await jsonOrNull(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 { + 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(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 { + 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(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to load issue comments'); + } + return payload; + }, });