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:
Bohdan Triapitsyn
2026-01-24 00:30:34 +02:00
parent da20c647e2
commit 57f00be773
15 changed files with 1614 additions and 9 deletions
+25 -3
View File
@@ -145,14 +145,16 @@ Implemented code pointers
## Feature B: Start Session From GitHub Issue ## Feature B: Start Session From GitHub Issue
Status: implemented.
### Intent ### Intent
Create a new session seeded with issue context, without polluting chat with large issue bodies/comments. Create a new session seeded with issue context, without polluting chat with large issue bodies/comments.
### Entry Point ### Entry Point
Session kebab menu in `packages/ui/src/components/session/SessionSidebar.tsx`. Project header menu in `packages/ui/src/components/session/SessionSidebar.tsx`.
Add new item: Add new item:
- “New session from GitHub issue - “New session from GitHub issue”
### Modal UI ### Modal UI
Issue picker modal: Issue picker modal:
@@ -163,6 +165,10 @@ Issue picker modal:
- `#123` or `123` - `#123` or `123`
- checkbox: “Create in worktree” - checkbox: “Create in worktree”
Implementation notes:
- modal layout matches Timeline dialog styling/patterns
- “Open Repo” + per-issue “Open in GitHub” use `<a href=... target="_blank">` (desktop webview safe)
### Worktree option ### Worktree option
If enabled: If enabled:
- create a worktree session (reuse `createWorktreeSessionForBranch`) - create a worktree session (reuse `createWorktreeSessionForBranch`)
@@ -177,7 +183,7 @@ If disabled:
### Session Bootstrap (message) ### Session Bootstrap (message)
Send a single user message with: Send a single user message with:
1) Visible text part: concise prompt, e.g. 1) Visible text part: concise prompt, e.g.
- “Review the issue, clarify requirements, propose plan, then implement.” - “Review the issue; summarize requirements + unknowns; ask clarifying questions; gather needed code context; propose plan + next actions; do not implement until user confirms.”
2) Hidden synthetic parts: issue payload 2) Hidden synthetic parts: issue payload
- issue title/body - issue title/body
- labels, assignees, author - labels, assignees, author
@@ -192,6 +198,22 @@ Do not invent a new hidden-context mechanism.
- Get issue by number - Get issue by number
- List issue comments - List issue comments
Implemented code pointers
- UI modal: `packages/ui/src/components/session/GitHubIssuePickerDialog.tsx`
- Shared sendMessage synthetic parts: `packages/ui/src/lib/opencode/client.ts`
- Web server endpoints:
- `GET /api/github/issues/list`
- `GET /api/github/issues/get`
- `GET /api/github/issues/comments`
- Desktop Tauri commands:
- `github_issues_list`
- `github_issue_get`
- `github_issue_comments`
- VS Code bridge handlers:
- `api:github/issues:list`
- `api:github/issues:get`
- `api:github/issues:comments`
## Feature C: Start Session From GitHub PR (with worktree checkout) ## Feature C: Start Session From GitHub PR (with worktree checkout)
### Intent ### Intent
@@ -84,6 +84,86 @@ pub struct GitHubPullRequestReadyResult {
ready: bool, ready: bool,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueLabel {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueSummary {
number: u64,
title: String,
url: String,
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
labels: Option<Vec<GitHubIssueLabel>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssue {
#[serde(flatten)]
summary: GitHubIssueSummary,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assignees: Option<Vec<GitHubUserSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueComment {
id: u64,
url: String,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssuesListResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
issues: Option<Vec<GitHubIssueSummary>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueGetResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
issue: Option<GitHubIssue>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueCommentsResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
comments: Option<Vec<GitHubIssueComment>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GitHubUserSummary { pub struct GitHubUserSummary {
@@ -205,6 +285,72 @@ struct ApiUserResponse {
email: Option<String>, email: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct IssueUser {
login: String,
#[serde(default)]
id: Option<u64>,
#[serde(default)]
avatar_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IssueLabel {
name: String,
#[serde(default)]
color: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IssueListItem {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
labels: Vec<IssueLabel>,
#[serde(default)]
pull_request: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct IssueDetailsResponse {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
labels: Vec<IssueLabel>,
#[serde(default)]
assignees: Vec<IssueUser>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
pull_request: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct IssueCommentResponse {
id: u64,
html_url: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PrListItem { struct PrListItem {
number: u64, number: u64,
@@ -591,6 +737,27 @@ async fn fetch_me(access_token: &str) -> Result<GitHubUserSummary, String> {
}) })
} }
fn map_issue_user(user: &IssueUser) -> GitHubUserSummary {
GitHubUserSummary {
login: user.login.clone(),
id: user.id,
avatar_url: user.avatar_url.clone(),
name: None,
email: None,
}
}
fn map_issue_labels(labels: Vec<IssueLabel>) -> Vec<GitHubIssueLabel> {
labels
.into_iter()
.filter(|l| !l.name.trim().is_empty())
.map(|l| GitHubIssueLabel {
name: l.name,
color: l.color,
})
.collect()
}
#[tauri::command] #[tauri::command]
pub async fn github_auth_status( pub async fn github_auth_status(
_state: State<'_, DesktopRuntime>, _state: State<'_, DesktopRuntime>,
@@ -1207,3 +1374,246 @@ pub async fn github_pr_ready(
Ok(GitHubPullRequestReadyResult { ready: true }) Ok(GitHubPullRequestReadyResult { ready: true })
} }
#[tauri::command]
pub async fn github_issues_list(
directory: String,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssuesListResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssuesListResult {
connected: true,
repo: None,
issues: Some(vec![]),
});
};
let url = format!(
"{}/{}/{}/issues?state=open&per_page=50",
API_PULLS_URL_PREFIX, repo.owner, repo.repo
);
let list = github_get_json::<Vec<IssueListItem>>(&url, &stored.access_token).await;
let list = match list {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
});
}
Err(err) => return Err(err),
};
let issues = list
.into_iter()
.filter(|item| item.pull_request.is_none())
.map(|item| GitHubIssueSummary {
number: item.number,
title: item.title,
url: item.html_url,
state: item.state,
author: item.user.as_ref().map(map_issue_user),
labels: Some(map_issue_labels(item.labels)),
})
.collect::<Vec<_>>();
Ok(GitHubIssuesListResult {
connected: true,
repo: Some(repo),
issues: Some(issues),
})
}
#[tauri::command]
pub async fn github_issue_get(
directory: String,
number: u64,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssueGetResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssueGetResult {
connected: true,
repo: None,
issue: None,
});
};
let url = format!(
"{}/{}/{}/issues/{}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let issue = github_get_json::<IssueDetailsResponse>(&url, &stored.access_token).await;
let issue = match issue {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
}
Err(err) => return Err(err),
};
if issue.pull_request.is_some() {
return Err("Not a GitHub issue".to_string());
}
let summary = GitHubIssueSummary {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state,
author: issue.user.as_ref().map(map_issue_user),
labels: Some(map_issue_labels(issue.labels)),
};
let assignees = issue
.assignees
.iter()
.map(map_issue_user)
.collect::<Vec<_>>();
Ok(GitHubIssueGetResult {
connected: true,
repo: Some(repo),
issue: Some(GitHubIssue {
summary,
body: issue.body,
assignees: Some(assignees),
created_at: issue.created_at,
updated_at: issue.updated_at,
}),
})
}
#[tauri::command]
pub async fn github_issue_comments(
directory: String,
number: u64,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssueCommentsResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssueCommentsResult {
connected: true,
repo: None,
comments: Some(vec![]),
});
};
let url = format!(
"{}/{}/{}/issues/{}/comments?per_page=100",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let comments = github_get_json::<Vec<IssueCommentResponse>>(&url, &stored.access_token).await;
let comments = match comments {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
}
Err(err) => return Err(err),
};
let mapped = comments
.into_iter()
.map(|c| GitHubIssueComment {
id: c.id,
url: c.html_url,
body: c.body.unwrap_or_default(),
author: c.user.as_ref().map(map_issue_user),
created_at: c.created_at,
updated_at: c.updated_at,
})
.collect::<Vec<_>>();
Ok(GitHubIssueCommentsResult {
connected: true,
repo: Some(repo),
comments: Some(mapped),
})
}
+4
View File
@@ -46,6 +46,7 @@ use commands::logs::fetch_desktop_logs;
use commands::github::{ use commands::github::{
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me, github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_issue_comments, github_issue_get, github_issues_list,
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status, github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
}; };
use commands::notifications::desktop_notify; use commands::notifications::desktop_notify;
@@ -906,6 +907,9 @@ fn main() {
github_pr_create, github_pr_create,
github_pr_merge, github_pr_merge,
github_pr_ready, github_pr_ready,
github_issues_list,
github_issue_get,
github_issue_comments,
]) ])
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
+18
View File
@@ -1,6 +1,9 @@
import type { import type {
GitHubAPI, GitHubAPI,
GitHubAuthStatus, GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequest, GitHubPullRequest,
GitHubPullRequestCreateInput, GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput, GitHubPullRequestMergeInput,
@@ -59,4 +62,19 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
const { safeInvoke } = await import('../lib/tauriCallbackManager'); const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 }); return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
}, },
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory }, { timeout: 20000 });
},
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssueGetResult>('github_issue_get', { directory, number }, { timeout: 20000 });
},
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
},
}); });
@@ -44,8 +44,10 @@ export const GitHubSettings: React.FC = () => {
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop; const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) { if (desktop?.openExternal) {
try { try {
await desktop.openExternal(url); const result = await desktop.openExternal(url);
return; if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
} catch { } catch {
// fall through // fall through
} }
@@ -0,0 +1,550 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import {
RiCheckboxBlankLine,
RiCheckboxLine,
RiExternalLinkLine,
RiGithubLine,
RiLoader4Line,
RiSearchLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
import { createBranch } from '@/lib/gitApi';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } from '@/lib/api/types';
const parseIssueNumber = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/\/issues\/(\d+)(?:\b|\/|$)/i);
if (urlMatch) {
const parsed = Number(urlMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const hashMatch = trimmed.match(/^#?(\d+)$/);
if (hashMatch) {
const parsed = Number(hashMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
};
const sanitizeSlug = (value: string): string => {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 80);
};
const buildIssueContextText = (args: {
repo: GitHubIssuesListResult['repo'] | undefined;
issue: GitHubIssue;
comments: GitHubIssueComment[];
}) => {
const payload = {
repo: args.repo ?? null,
issue: args.issue,
comments: args.comments,
};
return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
export function GitHubIssuePickerDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { github } = useRuntimeAPIs();
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
const [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false);
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
return;
}
if (!github?.issuesList) {
setResult(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.issuesList(projectDirectory);
setResult(next);
if (next.connected === false) {
setError(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [github, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
setCreateInWorktree(false);
setStartingIssueNumber(null);
setError(null);
setResult(null);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]);
const connected = Boolean(result?.connected);
const repoUrl = result?.repo?.url ?? null;
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter((issue) => {
if (String(issue.number) === q.replace(/^#/, '')) return true;
return issue.title.toLowerCase().includes(q);
});
}, [issues, query]);
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
const configState = useConfigStore.getState();
const visibleAgents = configState.getVisibleAgents();
if (configState.settingsDefaultAgent) {
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
if (settingsAgent) {
return settingsAgent.name;
}
}
return (
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name
);
}, []);
const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => {
const configState = useConfigStore.getState();
const settingsDefaultModel = configState.settingsDefaultModel;
if (!settingsDefaultModel) {
return null;
}
const parts = settingsDefaultModel.split('/');
if (parts.length !== 2) {
return null;
}
const [providerID, modelID] = parts;
if (!providerID || !modelID) {
return null;
}
const modelMetadata = configState.getModelMetadata(providerID, modelID);
if (!modelMetadata) {
return null;
}
return { providerID, modelID };
}, []);
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
const configState = useConfigStore.getState();
const settingsDefaultVariant = configState.settingsDefaultVariant;
if (!settingsDefaultVariant) {
return undefined;
}
const provider = configState.providers.find((p) => p.id === providerID);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| undefined;
const variants = model?.variants;
if (!variants) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
return undefined;
}
return settingsDefaultVariant;
}, []);
const buildUniqueIssueBranchName = React.useCallback(
async (issue: GitHubIssue) => {
const titleSlug = sanitizeSlug(issue.title);
const base = titleSlug ? `issue-${issue.number}-${titleSlug}` : `issue-${issue.number}`;
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
for (let attempt = 0; attempt < 6; attempt += 1) {
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
try {
const created = await createBranch(projectDirectory || '', candidate, startPoint);
if (created?.success) {
return candidate;
}
} catch {
// try next
}
}
throw new Error('Failed to create issue branch');
},
[baseBranch, projectDirectory]
);
const startSession = React.useCallback(async (issueNumber: number) => {
if (!projectDirectory) {
toast.error('No active project');
return;
}
if (!github?.issueGet || !github?.issueComments) {
toast.error('GitHub runtime API unavailable');
return;
}
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error('GitHub not connected');
return;
}
if (!issueRes.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error('Issue not found');
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error('GitHub not connected');
return;
}
const comments = commentsRes.comments ?? [];
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const sessionId = await (async () => {
if (createInWorktree) {
const branchName = await buildUniqueIssueBranchName(issue);
const created = await createWorktreeSessionForBranch(projectDirectory, branchName);
if (!created?.id) {
throw new Error('Failed to create worktree session');
}
return created.id;
}
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
return session.id;
})();
// Ensure worktree-based sessions also get the issue title.
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
try {
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
} catch {
// ignore
}
// Close modal immediately after session exists (don't wait for message send).
onOpenChange(false);
const configState = useConfigStore.getState();
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
const defaultModel = resolveDefaultModelSelection();
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
if (!providerID || !modelID) {
toast.error('No model selected');
return;
}
const variant = resolveDefaultVariant(providerID, modelID);
try {
useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID);
} catch {
// ignore
}
if (agentName) {
try {
configState.setAgent(agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveSessionAgentSelection(sessionId, agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID);
} catch {
// ignore
}
if (variant !== undefined) {
try {
configState.setCurrentVariant(variant);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant);
} catch {
// ignore
}
}
}
const promptText =
'Review this GitHub issue. Summarize requirements + unknowns, ask clarifying questions, gather any needed code context, then propose a plan and next actions. Do not implement until I confirm.';
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
void opencodeClient.sendMessage({
id: sessionId,
providerID,
modelID,
agent: agentName,
variant,
text: promptText,
additionalParts: [{ text: contextText, synthetic: true }],
}).catch((e) => {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to send issue context', {
description: message,
});
});
toast.success('Session created from issue');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to start session', { description: message });
} finally {
setStartingIssueNumber(null);
}
}, [
buildUniqueIssueBranchName,
createInWorktree,
github,
onOpenChange,
projectDirectory,
resolveDefaultAgentName,
resolveDefaultModelSelection,
resolveDefaultVariant,
startingIssueNumber,
]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
New Session From GitHub Issue
</DialogTitle>
<DialogDescription>
Seeds a new session with hidden issue context (title/body/labels/comments).
</DialogDescription>
</DialogHeader>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste issue URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className="flex-1 overflow-y-auto mt-2">
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading issues...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8">GitHub not connected.</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && github && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
startingIssueNumber === directNumber && 'bg-muted/30'
)}
onClick={() => void startSession(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use issue #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === directNumber ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No issues found' : 'No open issues found'}</div>
) : null}
{filtered.map((issue) => (
<div
key={issue.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-muted/30'
)}
onClick={() => void startSession(issue.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{issue.title}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={createInWorktree}
onClick={() => setCreateInWorktree((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setCreateInWorktree((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateInWorktree((v) => !v);
}}
aria-label="Toggle worktree"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
<RiCheckboxLine className="h-4 w-4 text-primary" />
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">Create in worktree</span>
<span className="typography-meta text-muted-foreground/70">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</div>
<div className="flex-1" />
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open Repo
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingIssueNumber)}>
Refresh
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -42,6 +42,8 @@ import {
RiGitRepositoryLine, RiGitRepositoryLine,
RiLinkUnlinkM, RiLinkUnlinkM,
RiGithubLine,
RiMore2Line, RiMore2Line,
RiPencilAiLine, RiPencilAiLine,
RiShare2Line, RiShare2Line,
@@ -62,6 +64,7 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { BranchPickerDialog } from './BranchPickerDialog'; import { BranchPickerDialog } from './BranchPickerDialog';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
@@ -139,6 +142,7 @@ interface SortableProjectItemProps {
onNewSession: () => void; onNewSession: () => void;
onNewWorktreeSession?: () => void; onNewWorktreeSession?: () => void;
onOpenBranchPicker?: () => void; onOpenBranchPicker?: () => void;
onNewSessionFromGitHubIssue?: () => void;
onOpenMultiRunLauncher: () => void; onOpenMultiRunLauncher: () => void;
onClose: () => void; onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void; sentinelRef: (el: HTMLDivElement | null) => void;
@@ -163,6 +167,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewSession, onNewSession,
onNewWorktreeSession, onNewWorktreeSession,
onOpenBranchPicker, onOpenBranchPicker,
onNewSessionFromGitHubIssue,
onOpenMultiRunLauncher, onOpenMultiRunLauncher,
onClose, onClose,
sentinelRef, sentinelRef,
@@ -273,6 +278,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
Browse Branches Browse Branches
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
<RiGithubLine className="mr-1.5 h-4 w-4" />
New session from GitHub issue
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && ( {isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}> <DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" /> <ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -399,6 +410,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null); const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false); const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null); const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set()); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null); const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
@@ -1631,6 +1643,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
createWorktreeSession(); createWorktreeSession();
}} }}
onOpenBranchPicker={() => setBranchPickerOpen(true)} onOpenBranchPicker={() => setBranchPickerOpen(true)}
onNewSessionFromGitHubIssue={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
}
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
setIssuePickerOpen(true);
}}
onOpenMultiRunLauncher={() => { onOpenMultiRunLauncher={() => {
if (projectKey !== activeProjectId) { if (projectKey !== activeProjectId) {
setActiveProject(projectKey); setActiveProject(projectKey);
@@ -1672,6 +1694,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projects={normalizedProjects} projects={normalizedProjects}
activeProjectId={activeProjectId} activeProjectId={activeProjectId}
/> />
<GitHubIssuePickerDialog
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
/>
</div> </div>
); );
}; };
@@ -52,8 +52,10 @@ const openExternal = async (url: string) => {
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop; const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) { if (desktop?.openExternal) {
try { try {
await desktop.openExternal(url); const result = await desktop.openExternal(url);
return; if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
} catch { } catch {
// fall through // fall through
} }
+52
View File
@@ -560,6 +560,54 @@ export type GitHubPullRequestMergeResult = {
message?: string; message?: string;
}; };
export type GitHubIssueLabel = {
name: string;
color?: string;
};
export type GitHubIssueSummary = {
number: number;
title: string;
url: string;
state: 'open' | 'closed';
author?: GitHubUserSummary | null;
labels?: GitHubIssueLabel[];
};
export type GitHubIssue = GitHubIssueSummary & {
body?: string;
assignees?: GitHubUserSummary[];
createdAt?: string;
updatedAt?: string;
};
export type GitHubIssueComment = {
id: number;
url: string;
body: string;
author?: GitHubUserSummary | null;
createdAt?: string;
updatedAt?: string;
};
export type GitHubIssuesListResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
issues?: GitHubIssueSummary[];
};
export type GitHubIssueGetResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
issue?: GitHubIssue | null;
};
export type GitHubIssueCommentsResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
comments?: GitHubIssueComment[];
};
export type GitHubAuthStatus = { export type GitHubAuthStatus = {
connected: boolean; connected: boolean;
user?: GitHubUserSummary | null; user?: GitHubUserSummary | null;
@@ -591,6 +639,10 @@ export interface GitHubAPI {
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>; prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>; prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>; prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
issuesList(directory: string): Promise<GitHubIssuesListResult>;
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
} }
export interface RuntimeAPIs { export interface RuntimeAPIs {
+6 -2
View File
@@ -598,6 +598,7 @@ class OpencodeService {
modelID: string; modelID: string;
text: string; text: string;
prefaceText?: string; prefaceText?: string;
prefaceTextSynthetic?: boolean;
agent?: string; agent?: string;
variant?: string; variant?: string;
files?: Array<{ files?: Array<{
@@ -609,6 +610,7 @@ class OpencodeService {
/** Additional text/file parts to include (for batch sending queued messages) */ /** Additional text/file parts to include (for batch sending queued messages) */
additionalParts?: Array<{ additionalParts?: Array<{
text: string; text: string;
synthetic?: boolean;
files?: Array<{ files?: Array<{
type: 'file'; type: 'file';
mime: string; mime: string;
@@ -630,7 +632,8 @@ class OpencodeService {
if (params.prefaceText && params.prefaceText.trim()) { if (params.prefaceText && params.prefaceText.trim()) {
parts.push({ parts.push({
type: 'text', type: 'text',
text: params.prefaceText text: params.prefaceText,
synthetic: params.prefaceTextSynthetic !== false,
}); });
} }
@@ -663,7 +666,8 @@ class OpencodeService {
if (additional.text && additional.text.trim()) { if (additional.text && additional.text.trim()) {
parts.push({ parts.push({
type: 'text', type: 'text',
text: additional.text text: additional.text,
...(additional.synthetic ? { synthetic: true } : {}),
}); });
} }
if (additional.files && additional.files.length > 0) { if (additional.files && additional.files.length > 0) {
+77
View File
@@ -29,6 +29,12 @@ import {
mergePullRequest, mergePullRequest,
} from './githubPr'; } from './githubPr';
import {
getIssue,
listIssueComments,
listIssues,
} from './githubIssues';
export interface BridgeRequest { export interface BridgeRequest {
id: string; id: string;
type: string; type: string;
@@ -1171,6 +1177,77 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
} }
} }
case 'api:github/issues:list': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const stored = await readGitHubAuth(context);
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false } };
}
const directory = readStringField(payload, 'directory');
if (!directory) {
return { id, type, success: false, error: 'directory is required' };
}
try {
const result = await listIssues(stored.accessToken, directory);
if (result.connected === false) {
await clearGitHubAuth(context);
}
return { id, type, success: true, data: result };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: message };
}
}
case 'api:github/issues:get': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const stored = await readGitHubAuth(context);
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false } };
}
const directory = readStringField(payload, 'directory');
const number = readNumberField(payload, 'number') ?? 0;
if (!directory || !number) {
return { id, type, success: false, error: 'directory and number are required' };
}
try {
const result = await getIssue(stored.accessToken, directory, number);
if (result.connected === false) {
await clearGitHubAuth(context);
}
return { id, type, success: true, data: result };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: message };
}
}
case 'api:github/issues:comments': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const stored = await readGitHubAuth(context);
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false } };
}
const directory = readStringField(payload, 'directory');
const number = readNumberField(payload, 'number') ?? 0;
if (!directory || !number) {
return { id, type, success: false, error: 'directory and number are required' };
}
try {
const result = await listIssueComments(stored.accessToken, directory, number);
if (result.connected === false) {
await clearGitHubAuth(context);
}
return { id, type, success: true, data: result };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: message };
}
}
case 'api:config/reload': { case 'api:config/reload': {
await ctx?.manager?.restart(); await ctx?.manager?.restart();
return { id, type, success: true, data: { restarted: true } }; return { id, type, success: true, data: { restarted: true } };
+230
View File
@@ -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 || [] };
};
+10
View File
@@ -1,6 +1,9 @@
import type { import type {
GitHubAPI, GitHubAPI,
GitHubAuthStatus, GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequest, GitHubPullRequest,
GitHubPullRequestCreateInput, GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput, GitHubPullRequestMergeInput,
@@ -31,4 +34,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload), sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
prReady: async (payload: GitHubPullRequestReadyInput) => prReady: async (payload: GitHubPullRequestReadyInput) =>
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload), sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
issuesList: async (directory: string) =>
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory }),
issueGet: async (directory: string, number: number) =>
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number }),
issueComments: async (directory: string, number: number) =>
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number }),
}); });
+158
View File
@@ -4360,6 +4360,164 @@ async function main(options = {}) {
} }
}); });
// ================= GitHub Issue APIs =================
app.get('/api/github/issues/list', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, issues: [] });
}
const list = await octokit.rest.issues.listForRepo({
owner: repo.owner,
repo: repo.repo,
state: 'open',
per_page: 50,
});
const issues = (Array.isArray(list?.data) ? list.data : [])
.filter((item) => !item?.pull_request)
.map((item) => ({
number: item.number,
title: item.title,
url: item.html_url,
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
labels: Array.isArray(item.labels)
? item.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
}));
return res.json({ connected: true, repo, issues });
} catch (error) {
console.error('Failed to list GitHub issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
}
});
app.get('/api/github/issues/get', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
const issue = result?.data;
if (!issue || issue.pull_request) {
return res.status(400).json({ error: 'Not a GitHub issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state === 'closed' ? 'closed' : 'open',
body: issue.body || '',
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
assignees: Array.isArray(issue.assignees)
? issue.assignees
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
.filter(Boolean)
: [],
labels: Array.isArray(issue.labels)
? issue.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
},
});
} catch (error) {
console.error('Failed to fetch GitHub issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
}
});
app.get('/api/github/issues/comments', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.json({ connected: true, repo: null, comments: [] });
}
const result = await octokit.rest.issues.listComments({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
per_page: 100,
});
const comments = (Array.isArray(result?.data) ? result.data : [])
.map((comment) => ({
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
}));
return res.json({ connected: true, repo, comments });
} catch (error) {
console.error('Failed to fetch GitHub issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => { app.get('/api/provider/:providerId/source', async (req, res) => {
try { try {
const { providerId } = req.params; const { providerId } = req.params;
+39
View File
@@ -1,6 +1,9 @@
import type { import type {
GitHubAPI, GitHubAPI,
GitHubAuthStatus, GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequest, GitHubPullRequest,
GitHubPullRequestCreateInput, GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput, GitHubPullRequestMergeInput,
@@ -121,4 +124,40 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
} }
return body; return body;
}, },
async issuesList(directory: string): Promise<GitHubIssuesListResult> {
const response = await fetch(
`/api/github/issues/list?directory=${encodeURIComponent(directory)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issues');
}
return payload;
},
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
const response = await fetch(
`/api/github/issues/get?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssueGetResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue');
}
return payload;
},
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
const response = await fetch(
`/api/github/issues/comments?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssueCommentsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');
}
return payload;
},
}); });