diff --git a/bun.lock b/bun.lock index 24145fee..25623371 100644 --- a/bun.lock +++ b/bun.lock @@ -95,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.5.5", + "version": "1.5.6", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -118,7 +118,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.5.5", + "version": "1.5.6", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -212,7 +212,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.5.5", + "version": "1.5.6", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.19", @@ -235,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.5.5", + "version": "1.5.6", "bin": { "openchamber": "./bin/cli.js", }, @@ -243,6 +243,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", + "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.1.19", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 17017fef..245bcc73 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.5.5" +version = "1.5.6" dependencies = [ "anyhow", "axum", diff --git a/packages/desktop/src-tauri/src/commands/github.rs b/packages/desktop/src-tauri/src/commands/github.rs index aff0a60b..a5fcdf14 100644 --- a/packages/desktop/src-tauri/src/commands/github.rs +++ b/packages/desktop/src-tauri/src/commands/github.rs @@ -82,6 +82,75 @@ pub struct GitHubPullRequestContextResult { diff: Option, #[serde(skip_serializing_if = "Option::is_none")] checks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + check_runs: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubCheckRun { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + conclusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + job: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubCheckRunApp { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + slug: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubCheckRunJob { + #[serde(skip_serializing_if = "Option::is_none")] + run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + job_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + conclusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + steps: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubCheckRunJobStep { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + conclusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + number: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubCheckRunOutput { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -540,10 +609,36 @@ struct CombinedStatusResponse { #[derive(Debug, Deserialize)] struct CheckRunEntry { + #[serde(default)] + name: Option, + #[serde(default)] + app: Option, #[serde(default)] status: Option, #[serde(default)] conclusion: Option, + #[serde(default)] + details_url: Option, + #[serde(default)] + output: Option, +} + +#[derive(Debug, Deserialize)] +struct CheckRunApp { + #[serde(default)] + name: Option, + #[serde(default)] + slug: Option, +} + +#[derive(Debug, Deserialize)] +struct CheckRunOutput { + #[serde(default)] + title: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + text: Option, } #[derive(Debug, Deserialize)] @@ -1151,7 +1246,32 @@ pub async fn github_pr_status( Err(err) => return Err(err), }; - let Some(first) = list.first() else { + let mut first_number = list.first().map(|p| p.number); + + // Fork PR support: if head owner differs, head filter returns empty. + // Fall back to listing open PRs and matching by head ref name. + if first_number.is_none() { + let open_list_url = format!( + "{}/{}/{}/pulls?state=open&per_page=100", + API_PULLS_URL_PREFIX, repo.owner, repo.repo + ); + let open_list = github_get_json::>(&open_list_url, &stored.access_token).await; + if let Ok(items) = open_list { + for item in items.iter() { + let head_ref = item + .get("head") + .and_then(|h| h.get("ref")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if head_ref == branch { + first_number = item.get("number").and_then(|v| v.as_u64()); + break; + } + } + } + } + + let Some(first_number) = first_number else { return Ok(GitHubPullRequestStatus { connected: true, repo: Some(repo), @@ -1164,7 +1284,7 @@ pub async fn github_pr_status( let pr_url = format!( "{}/{}/{}/pulls/{}", - API_PULLS_URL_PREFIX, repo.owner, repo.repo, first.number + API_PULLS_URL_PREFIX, repo.owner, repo.repo, first_number ); let pr = github_get_json::(&pr_url, &stored.access_token).await?; @@ -2003,6 +2123,8 @@ pub async fn github_pr_context( number: u64, #[allow(non_snake_case)] includeDiff: bool, + #[allow(non_snake_case)] + includeCheckDetails: Option, _state: State<'_, DesktopRuntime>, ) -> Result { let directory = directory.trim().to_string(); @@ -2024,6 +2146,7 @@ pub async fn github_pr_context( files: None, diff: None, checks: None, + check_runs: None, }); }; if stored.access_token.trim().is_empty() { @@ -2037,6 +2160,7 @@ pub async fn github_pr_context( files: None, diff: None, checks: None, + check_runs: None, }); } @@ -2051,6 +2175,7 @@ pub async fn github_pr_context( files: None, diff: None, checks: None, + check_runs: None, }); }; @@ -2069,6 +2194,7 @@ pub async fn github_pr_context( files: None, diff: None, checks: None, + check_runs: None, }); } Err(err) => return Err(err), @@ -2170,6 +2296,12 @@ pub async fn github_pr_context( // checks summary (same as github_pr_status) let mut checks: Option = None; + let mut check_runs_out: Option> = None; + let include_check_details = includeCheckDetails.unwrap_or(false); + + // actions jobs cache per run_id + let mut jobs_by_run_id: std::collections::HashMap> = std::collections::HashMap::new(); + if let Some(ref sha) = pr.summary.head_sha { let check_runs_url = format!( "{}/{}/{}/commits/{}/check-runs", @@ -2177,6 +2309,139 @@ pub async fn github_pr_context( ); if let Ok(runs) = github_get_json::(&check_runs_url, &stored.access_token).await { if !runs.check_runs.is_empty() { + let mut out: Vec = Vec::new(); + + for run in runs.check_runs.iter() { + let name = run.name.clone().unwrap_or_default(); + if name.trim().is_empty() { + continue; + } + + let mut job: Option = None; + if include_check_details { + if let Some(details_url) = &run.details_url { + let (run_id, job_id) = (|| { + let marker = "/actions/runs/"; + let idx = details_url.find(marker)?; + let rest = &details_url[(idx + marker.len())..]; + let mut iter = rest.split('/'); + let run_id_str = iter.next()?; + let run_id_val = run_id_str.parse::().ok()?; + let mut job_id_val: Option = None; + let next = iter.next().unwrap_or(""); + if next == "job" { + job_id_val = iter.next().and_then(|s| s.parse::().ok()); + } + Some((run_id_val, job_id_val)) + })().unwrap_or((0, None)); + + if run_id > 0 { + if !jobs_by_run_id.contains_key(&run_id) { + let jobs_url = format!( + "{}/{}/{}/actions/runs/{}/jobs?per_page=100", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, run_id + ); + let jobs_json = github_get_json::(&jobs_url, &stored.access_token).await; + let jobs = jobs_json + .ok() + .and_then(|v| v.get("jobs").cloned()) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + jobs_by_run_id.insert(run_id, jobs); + } + + let jobs = jobs_by_run_id.get(&run_id).cloned().unwrap_or_default(); + let picked = if let Some(job_id_val) = job_id { + jobs.iter() + .find(|j| j.get("id").and_then(|v| v.as_u64()) == Some(job_id_val)) + .cloned() + } else { + jobs.iter() + .find(|j| j.get("name").and_then(|v| v.as_str()) == Some(name.as_str())) + .cloned() + }; + + if let Some(picked) = picked { + let steps = picked + .get("steps") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| { + let step_name = s + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if step_name.trim().is_empty() { + return None; + } + Some(GitHubCheckRunJobStep { + name: step_name.to_string(), + status: s + .get("status") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + conclusion: s + .get("conclusion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + number: s.get("number").and_then(|v| v.as_u64()), + }) + }) + .collect::>() + }); + + job = Some(GitHubCheckRunJob { + run_id: Some(run_id), + job_id: picked.get("id").and_then(|v| v.as_u64()), + url: picked + .get("html_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + name: picked + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + conclusion: picked + .get("conclusion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + steps, + }); + } else { + job = Some(GitHubCheckRunJob { + run_id: Some(run_id), + job_id, + url: Some(details_url.clone()), + name: None, + conclusion: None, + steps: None, + }); + } + } + } + } + + out.push(GitHubCheckRun { + name, + app: run.app.as_ref().map(|a| GitHubCheckRunApp { + name: a.name.clone(), + slug: a.slug.clone(), + }), + status: run.status.clone(), + conclusion: run.conclusion.clone(), + details_url: run.details_url.clone(), + output: run.output.as_ref().map(|o| GitHubCheckRunOutput { + title: o.title.clone(), + summary: o.summary.clone(), + text: o.text.clone(), + }), + job, + }); + } + + check_runs_out = Some(out); + let mut success = 0; let mut failure = 0; let mut pending = 0; @@ -2270,6 +2535,7 @@ pub async fn github_pr_context( files: None, diff: None, checks: None, + check_runs: None, }); } Err(_) => None, @@ -2287,5 +2553,6 @@ pub async fn github_pr_context( files: Some(files), diff, checks, + check_runs: check_runs_out, }) } diff --git a/packages/desktop/src/api/github.ts b/packages/desktop/src/api/github.ts index b331f359..a92f2e32 100644 --- a/packages/desktop/src/api/github.ts +++ b/packages/desktop/src/api/github.ts @@ -85,11 +85,15 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({ return safeInvoke('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 }); }, - async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise { + async prContext( + directory: string, + number: number, + options?: { includeDiff?: boolean; includeCheckDetails?: boolean } + ): Promise { const { safeInvoke } = await import('../lib/tauriCallbackManager'); return safeInvoke( 'github_pr_context', - { directory, number, includeDiff: Boolean(options?.includeDiff) }, + { directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails) }, { timeout: 30000 } ); }, diff --git a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx index 3fb00c35..60cd2524 100644 --- a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx @@ -261,7 +261,7 @@ export function GitHubPullRequestPickerDialog({ if (startingNumber) return; setStartingNumber(number); try { - const prContext = await github.prContext(projectDirectory, number, { includeDiff }); + const prContext = await github.prContext(projectDirectory, number, { includeDiff, includeCheckDetails: false }); if (prContext.connected === false) { toast.error('GitHub not connected'); return; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index a05ba0fe..f56c4227 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -9,6 +9,13 @@ import { } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { @@ -19,8 +26,13 @@ import { import { generatePullRequestDescription } from '@/lib/gitApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; +import { useMessageStore } from '@/stores/messageStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import type { GitHubPullRequest, + GitHubCheckRun, + GitHubPullRequestContextResult, GitHubPullRequestStatus, } from '@/lib/api/types'; @@ -76,6 +88,8 @@ export const PullRequestSection: React.FC<{ const { github } = useRuntimeAPIs(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSidebarSection = useUIStore((state) => state.setSidebarSection); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const currentSessionId = useSessionStore((state) => state.currentSessionId); const openGitHubSettings = React.useCallback(() => { setSidebarSection('settings'); @@ -97,8 +111,224 @@ export const PullRequestSection: React.FC<{ const [isMerging, setIsMerging] = React.useState(false); const [isMarkingReady, setIsMarkingReady] = React.useState(false); + const [checksDialogOpen, setChecksDialogOpen] = React.useState(false); + const [checkDetails, setCheckDetails] = React.useState(null); + const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false); + const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); + const pr = status?.pr ?? null; + + const openChecksDialog = React.useCallback(async () => { + if (!github?.prContext) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (!pr) return; + + setChecksDialogOpen(true); + setIsLoadingCheckDetails(true); + try { + const ctx = await github.prContext(directory, pr.number, { + includeDiff: false, + includeCheckDetails: true, + }); + setCheckDetails(ctx); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load check details', { description: message }); + } finally { + setIsLoadingCheckDetails(false); + } + }, [directory, github, pr]); + + const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => { + const status = run.status || 'unknown'; + const conclusion = run.conclusion ?? undefined; + const statusText = conclusion ? `${status} / ${conclusion}` : status; + const appName = run.app?.name || run.app?.slug; + return ( +
+
+
{run.name}
+
+ {appName ? `${appName} · ${statusText}` : statusText} +
+ {run.output?.summary ? ( +
+ {run.output.summary} +
+ ) : null} + {run.job?.steps && run.job.steps.length > 0 ? ( +
+
Steps
+
+ {run.job.steps.map((step, idx) => { + const c = (step.conclusion || '').toLowerCase(); + const isFail = c && !['success', 'neutral', 'skipped'].includes(c); + return ( +
+ {step.name} + {step.conclusion ? {step.conclusion} : null} +
+ ); + })} +
+
+ ) : null} +
+ + {run.detailsUrl ? ( + + ) : null} +
+ ); + }, []); + + const sendFailedChecksToChat = React.useCallback(async () => { + setActiveMainTab('chat'); + + if (!github?.prContext) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (!directory || !pr) return; + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first.' }); + return; + } + + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const providerID = currentProviderId || lastUsedProvider?.providerID; + const modelID = currentModelId || lastUsedProvider?.modelID; + if (!providerID || !modelID) { + toast.error('No model selected'); + return; + } + + try { + const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true }); + const runs = context.checkRuns ?? []; + const failed = runs.filter((r) => { + const conclusion = typeof r.conclusion === 'string' ? r.conclusion.toLowerCase() : ''; + if (!conclusion) return false; + return !['success', 'neutral', 'skipped'].includes(conclusion); + }); + + if (failed.length === 0) { + toast.message('No failed checks'); + return; + } + + const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.'; + const instructionsText = `Use the attached checks payload. +- Summarize what is failing. +- Identify likely root cause(s). +- Propose a minimal fix plan and verification steps. +- No speculation: ask for missing info if needed.`; + const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({ + repo: context.repo ?? null, + pr: context.pr ?? null, + failedChecks: failed, + }, null, 2)}`; + + await useMessageStore.getState().sendMessage( + visibleText, + providerID, + modelID, + currentAgentName ?? undefined, + currentSessionId, + undefined, + null, + [ + { text: instructionsText, synthetic: true }, + { text: payloadText, synthetic: true }, + ], + currentVariant + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load checks', { description: message }); + } + }, [currentSessionId, directory, github, pr, setActiveMainTab]); + + const sendCommentsToChat = React.useCallback(async () => { + setActiveMainTab('chat'); + + if (!github?.prContext) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (!directory || !pr) return; + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first.' }); + return; + } + + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const providerID = currentProviderId || lastUsedProvider?.providerID; + const modelID = currentModelId || lastUsedProvider?.modelID; + if (!providerID || !modelID) { + toast.error('No model selected'); + return; + } + + try { + const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false }); + const issueComments = context.issueComments ?? []; + const reviewComments = context.reviewComments ?? []; + const total = issueComments.length + reviewComments.length; + if (total === 0) { + toast.message('No PR comments'); + return; + } + + const visibleText = 'Review these PR comments and propose the required changes and next actions. Do not implement until I confirm.'; + const instructionsText = `Use the attached comments payload. +- Identify required vs optional changes. +- Call out intent/implementation mismatch if present. +- Propose a minimal plan and verification steps. +- No speculation: ask for missing info if needed.`; + const payloadText = `GitHub PR comments (JSON)\n${JSON.stringify({ + repo: context.repo ?? null, + pr: context.pr ?? null, + issueComments, + reviewComments, + }, null, 2)}`; + + await useMessageStore.getState().sendMessage( + visibleText, + providerID, + modelID, + currentAgentName ?? undefined, + currentSessionId, + undefined, + null, + [ + { text: instructionsText, synthetic: true }, + { text: payloadText, synthetic: true }, + ], + currentVariant + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load PR comments', { description: message }); + } + }, [currentSessionId, directory, github, pr, setActiveMainTab]); + const refresh = React.useCallback(async () => { if (!canShow) return; if (!github?.prStatus) { @@ -236,7 +466,6 @@ export const PullRequestSection: React.FC<{ return null; } - const pr = status?.pr ?? null; const repoUrl = status?.repo?.url || null; const checks = status?.checks ?? null; const canMerge = Boolean(status?.canMerge); @@ -306,6 +535,27 @@ export const PullRequestSection: React.FC<{ {pr.mergeable === false ? ' · not mergeable' : ''} {typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''} +
+ {checks ? ( + + ) : null} + {checks?.failure ? ( + + ) : null} + +
{canMerge && pr.draft ? (
Draft PRs must be marked ready before merge. @@ -450,6 +700,50 @@ export const PullRequestSection: React.FC<{
+ + + + + + + Check Details + + + {pr ? `PR #${pr.number}` : 'Pull request'} + + + +
+ {isLoadingCheckDetails ? ( +
+ + Loading... +
+ ) : null} + + {!isLoadingCheckDetails ? ( +
+ {Array.isArray(checkDetails?.checkRuns) && checkDetails?.checkRuns.length > 0 ? ( + checkDetails.checkRuns.map((run, idx) => ( +
+ {renderCheckRunSummary(run)} +
+ )) + ) : ( +
No check details available.
+ )} +
+ ) : null} +
+ +
+
+ +
+ +
); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 1fc881fd..b8033c98 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -509,6 +509,31 @@ export type GitHubChecksSummary = { pending: number; }; +export type GitHubCheckRun = { + id?: number; + name: string; + app?: { + name?: string; + slug?: string; + }; + status?: string; + conclusion?: string | null; + detailsUrl?: string; + output?: { + title?: string; + summary?: string; + text?: string; + }; + job?: { + runId?: number; + jobId?: number; + url?: string; + name?: string; + conclusion?: string | null; + steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>; + }; +}; + export type GitHubPullRequest = { number: number; title: string; @@ -576,6 +601,7 @@ export type GitHubPullRequestContextResult = { files?: GitHubPullRequestFile[]; diff?: string; checks?: GitHubChecksSummary | null; + checkRuns?: GitHubCheckRun[]; }; export type GitHubPullRequestStatus = { @@ -699,7 +725,11 @@ export interface GitHubAPI { prReady(payload: GitHubPullRequestReadyInput): Promise; prsList(directory: string, options?: { page?: number }): Promise; - prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise; + prContext( + directory: string, + number: number, + options?: { includeDiff?: boolean; includeCheckDetails?: boolean } + ): Promise; issuesList(directory: string, options?: { page?: number }): Promise; issueGet(directory: string, number: number): Promise; diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 4669e1e9..a154347a 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -340,11 +340,12 @@ interface MessageState { sessionAbortFlags: Map; pendingAssistantHeaderSessions: Set; pendingUserMessageMetaBySession: Map; + } interface MessageActions { loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise; abortCurrentOperation: (currentSessionId?: string) => Promise; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; @@ -552,7 +553,7 @@ export const useMessageStore = create()( }); }, - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => { + sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => { if (!currentSessionId) { throw new Error("No session selected"); } @@ -677,6 +678,7 @@ export const useMessageStore = create()( // Convert additional parts to SDK format const additionalPartsPayload = additionalParts?.map((part) => ({ text: part.text, + synthetic: part.synthetic, files: part.attachments?.map((file) => ({ type: "file" as const, mime: file.mimeType, @@ -693,7 +695,7 @@ export const useMessageStore = create()( agent, variant, files: filePayloads.length > 0 ? filePayloads : undefined, - additionalParts: additionalPartsPayload, + additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, }); diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index d7f2cdc0..7231968b 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -1288,11 +1288,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const directory = readStringField(payload, 'directory'); const number = readNumberField(payload, 'number') ?? 0; const includeDiff = readBooleanField(payload, 'includeDiff') ?? false; + const includeCheckDetails = readBooleanField(payload, 'includeCheckDetails') ?? false; if (!directory || !number) { return { id, type, success: false, error: 'directory and number are required' }; } try { - const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff); + const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff, includeCheckDetails); if (result.connected === false) { await clearGitHubAuth(context); } diff --git a/packages/vscode/src/githubPr.ts b/packages/vscode/src/githubPr.ts index f71889af..a4103765 100644 --- a/packages/vscode/src/githubPr.ts +++ b/packages/vscode/src/githubPr.ts @@ -148,11 +148,34 @@ export const getPullRequestStatus = async ( return { connected: false }; } const list = await jsonOrNull>(listResp); - if (!listResp.ok || !Array.isArray(list) || list.length === 0) { - return { connected: true, repo, branch, pr: null, checks: null, canMerge: false }; + let number = (listResp.ok && Array.isArray(list) && list.length > 0) + ? list[0].number + : null; + + // Fork PR support: head owner differs -> head filter yields empty. + if (!number) { + const openListUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); + openListUrl.searchParams.set('state', 'open'); + openListUrl.searchParams.set('per_page', '100'); + const openResp = await githubFetch(openListUrl.toString(), accessToken); + if (openResp.status === 401) { + return { connected: false }; + } + const openList = await jsonOrNull>(openResp); + if (openResp.ok && Array.isArray(openList)) { + const match = openList.find((prItem) => { + const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null; + return readString(head?.ref) === branch; + }); + if (match && typeof match.number === 'number') { + number = match.number; + } + } } - const number = list[0].number; + if (!number) { + return { connected: true, repo, branch, pr: null, checks: null, canMerge: false }; + } const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken); if (prResp.status === 401) { return { connected: false }; diff --git a/packages/vscode/src/githubPulls.ts b/packages/vscode/src/githubPulls.ts index 0dd0d21e..3643d91b 100644 --- a/packages/vscode/src/githubPulls.ts +++ b/packages/vscode/src/githubPulls.ts @@ -16,6 +16,31 @@ type GitHubChecksSummary = { pending: number; }; +type GitHubCheckRun = { + id?: number; + name: string; + app?: { + name?: string; + slug?: string; + }; + status?: string; + conclusion?: string | null; + detailsUrl?: string; + output?: { + title?: string; + summary?: string; + text?: string; + }; + job?: { + runId?: number; + jobId?: number; + url?: string; + name?: string; + conclusion?: string | null; + steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>; + }; +}; + type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string }; type GitHubPullRequestSummary = { @@ -84,6 +109,7 @@ export type GitHubPullRequestContextResult = { files?: GitHubPullRequestFile[]; diff?: string; checks?: GitHubChecksSummary | null; + checkRuns?: GitHubCheckRun[]; }; const githubFetch = async ( @@ -149,13 +175,50 @@ const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => { }; }; -const computeChecks = async (accessToken: string, repo: GitHubRepoRef, sha: string): Promise => { +const computeChecks = async ( + accessToken: string, + repo: GitHubRepoRef, + sha: string +): Promise<{ summary: GitHubChecksSummary | null; runs: GitHubCheckRun[] }> => { const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken); + if (runsResp.status === 401) { + return { summary: null, runs: [] }; + } const runsJson = await jsonOrNull(runsResp); const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs) ? ((runsJson as JsonRecord).check_runs as unknown[]) : []; + const mappedRuns: GitHubCheckRun[] = runs + .map((r) => { + const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null; + const name = readString(rec?.name); + if (!name) return null; + const output = rec?.output && typeof rec.output === 'object' ? (rec.output as JsonRecord) : null; + const app = rec?.app && typeof rec.app === 'object' ? (rec.app as JsonRecord) : null; + return { + id: typeof rec?.id === 'number' ? rec.id : undefined, + name, + app: app + ? { + name: readString(app.name) || undefined, + slug: readString(app.slug) || undefined, + } + : undefined, + status: readString(rec?.status) || undefined, + conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') ? (rec?.conclusion as string | null) : undefined, + detailsUrl: readString(rec?.details_url) || undefined, + output: output + ? { + title: readString(output.title) || undefined, + summary: readString(output.summary) || undefined, + text: readString(output.text) || undefined, + } + : undefined, + }; + }) + .filter(Boolean) as GitHubCheckRun[]; + if (runsResp.ok && runs.length > 0) { const counts = { success: 0, failure: 0, pending: 0 }; runs.forEach((r) => { @@ -180,12 +243,12 @@ const computeChecks = async (accessToken: string, repo: GitHubRepoRef, sha: stri const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - return { state, total, ...counts }; + return { summary: { state, total, ...counts }, runs: mappedRuns }; } const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken); const statusJson = await jsonOrNull(statusResp); - if (!statusResp.ok || !statusJson) return null; + if (!statusResp.ok || !statusJson) return { summary: null, runs: mappedRuns }; const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : []; const counts = { success: 0, failure: 0, pending: 0 }; statuses.forEach((s) => { @@ -198,7 +261,7 @@ const computeChecks = async (accessToken: string, repo: GitHubRepoRef, sha: stri const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - return { state, total, ...counts }; + return { summary: { state, total, ...counts }, runs: mappedRuns }; }; export const listPullRequests = async ( @@ -259,6 +322,7 @@ export const getPullRequestContext = async ( directory: string, number: number, includeDiff: boolean, + includeCheckDetails: boolean, ): Promise => { const repo = await resolveRepoFromDirectory(directory); if (!repo) { @@ -358,7 +422,80 @@ export const getPullRequestContext = async ( }) .filter(Boolean) as GitHubPullRequestFile[]; - const checks = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : null; + const checksResult = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : { summary: null, runs: [] }; + const checks = checksResult.summary; + const checkRuns = checksResult.runs; + + if (includeCheckDetails && checkRuns.length > 0) { + const parseIds = (url: string | undefined): { runId: number | null; jobId: number | null } => { + if (!url) return { runId: null, jobId: null }; + const match = url.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + if (!match) return { runId: null, jobId: null }; + const runId = Number(match[1]); + const jobId = match[2] ? Number(match[2]) : null; + return { + runId: Number.isFinite(runId) && runId > 0 ? runId : null, + jobId: jobId && Number.isFinite(jobId) && jobId > 0 ? jobId : null, + }; + }; + + const jobsByRunId = new Map(); + const runIds = new Set(); + checkRuns.forEach((r) => { + const ids = parseIds(r.detailsUrl); + if (ids.runId) runIds.add(ids.runId); + }); + + for (const runId of runIds) { + const jobsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/actions/runs/${runId}/jobs?per_page=100`, accessToken); + if (jobsResp.status === 401) { + return { connected: false }; + } + const jobsJson = await jsonOrNull(jobsResp); + const jobs = Array.isArray(jobsJson?.jobs) ? (jobsJson?.jobs as unknown[]) : []; + jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]); + } + + for (const run of checkRuns) { + const ids = parseIds(run.detailsUrl); + if (!ids.runId) continue; + const jobs = jobsByRunId.get(ids.runId) ?? []; + const picked = ids.jobId + ? jobs.find((j) => typeof j.id === 'number' && j.id === ids.jobId) + : jobs.find((j) => readString(j.name) === run.name); + if (!picked) { + run.job = { runId: ids.runId, ...(ids.jobId ? { jobId: ids.jobId } : {}), url: run.detailsUrl }; + continue; + } + const stepsRaw = Array.isArray(picked.steps) ? (picked.steps as unknown[]) : []; + const steps = stepsRaw + .map((s) => { + const rec = s && typeof s === 'object' ? (s as JsonRecord) : null; + const name = readString(rec?.name); + if (!name) return null; + return { + name, + status: readString(rec?.status) || undefined, + conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') + ? (rec?.conclusion as string | null) + : undefined, + number: typeof rec?.number === 'number' ? rec.number : undefined, + }; + }) + .filter(Boolean) as Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>; + + run.job = { + runId: ids.runId, + jobId: typeof picked.id === 'number' ? picked.id : undefined, + url: readString(picked.html_url) || undefined, + name: readString(picked.name) || undefined, + conclusion: (picked.conclusion === null || typeof picked.conclusion === 'string') + ? (picked.conclusion as string | null) + : undefined, + steps: steps.length > 0 ? steps : undefined, + }; + } + } let diff: string | undefined; if (includeDiff) { @@ -369,5 +506,5 @@ export const getPullRequestContext = async ( } } - return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks }; + return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks, checkRuns }; }; diff --git a/packages/vscode/webview/api/github.ts b/packages/vscode/webview/api/github.ts index 371566b2..3bc9103f 100644 --- a/packages/vscode/webview/api/github.ts +++ b/packages/vscode/webview/api/github.ts @@ -46,6 +46,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({ prsList: async (directory: string, options?: { page?: number }) => sendBridgeMessage('api:github/pulls:list', { directory, page: options?.page ?? 1 }), - prContext: async (directory: string, number: number, options?: { includeDiff?: boolean }) => - sendBridgeMessage('api:github/pulls:context', { directory, number, includeDiff: Boolean(options?.includeDiff) }), + prContext: async (directory: string, number: number, options?: { includeDiff?: boolean; includeCheckDetails?: boolean }) => + sendBridgeMessage('api:github/pulls:context', { + directory, + number, + includeDiff: Boolean(options?.includeDiff), + includeCheckDetails: Boolean(options?.includeCheckDetails), + }), }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 64a2a3ed..664ca0bb 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -4082,7 +4082,23 @@ async function main(options = {}) { head: `${repo.owner}:${branch}`, per_page: 10, }); - const first = Array.isArray(list?.data) ? list.data[0] : null; + + let first = Array.isArray(list?.data) ? list.data[0] : null; + + // Fork PR support: head owner != base owner. If no PR found via head filter, + // fall back to listing open PRs and matching by head ref name. + if (!first) { + const openList = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 100, + }); + const matches = Array.isArray(openList?.data) + ? openList.data.filter((pr) => pr?.head?.ref === branch) + : []; + first = matches[0] ?? null; + } if (!first) { return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); } @@ -4601,6 +4617,7 @@ async function main(options = {}) { const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; + const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true'; if (!directory || !number) { return res.status(400).json({ error: 'directory and number are required' }); } @@ -4702,12 +4719,108 @@ async function main(options = {}) { // checks summary (same logic as status endpoint) let checks = null; + let checkRunsOut = undefined; const sha = prData.head?.sha; if (sha) { try { const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; if (checkRuns.length > 0) { + const parsedJobs = new Map(); + if (includeCheckDetails) { + // Prefetch actions jobs per runId. + const runIds = new Set(); + const jobIds = new Map(); + for (const run of checkRuns) { + const details = typeof run.details_url === 'string' ? run.details_url : ''; + const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + if (match) { + const runId = Number(match[1]); + const jobId = match[2] ? Number(match[2]) : null; + if (Number.isFinite(runId) && runId > 0) { + runIds.add(runId); + if (jobId && Number.isFinite(jobId) && jobId > 0) { + jobIds.set(details, { runId, jobId }); + } else { + jobIds.set(details, { runId, jobId: null }); + } + } + } + } + + for (const runId of runIds) { + try { + const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({ + owner: repo.owner, + repo: repo.repo, + run_id: runId, + per_page: 100, + }); + const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : []; + parsedJobs.set(runId, jobs); + } catch { + parsedJobs.set(runId, []); + } + } + } + + checkRunsOut = checkRuns.map((run) => { + const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined; + let job = undefined; + if (includeCheckDetails && detailsUrl) { + const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + const runId = match ? Number(match[1]) : null; + const jobId = match && match[2] ? Number(match[2]) : null; + if (runId && Number.isFinite(runId)) { + const jobs = parsedJobs.get(runId) || []; + const matched = jobId + ? jobs.find((j) => j.id === jobId) + : null; + const picked = matched || jobs.find((j) => j.name === run.name) || null; + if (picked) { + job = { + runId, + jobId: picked.id, + url: picked.html_url, + name: picked.name, + conclusion: picked.conclusion, + steps: Array.isArray(picked.steps) + ? picked.steps.map((s) => ({ + name: s.name, + status: s.status, + conclusion: s.conclusion, + number: s.number, + })) + : undefined, + }; + } else { + job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl }; + } + } + } + + return { + id: run.id, + name: run.name, + app: run.app + ? { + name: run.app.name || undefined, + slug: run.app.slug || undefined, + } + : undefined, + status: run.status, + conclusion: run.conclusion, + detailsUrl, + output: run.output + ? { + title: run.output.title || undefined, + summary: run.output.summary || undefined, + text: run.output.text || undefined, + } + : undefined, + ...(job ? { job } : {}), + }; + }); const counts = { success: 0, failure: 0, pending: 0 }; for (const run of checkRuns) { const status = run?.status; @@ -4772,6 +4885,7 @@ async function main(options = {}) { files, ...(diff ? { diff } : {}), checks, + ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}), }); } catch (error) { if (error?.status === 401) { diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index bc64e2ce..e99121e0 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -140,13 +140,20 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return body; }, - async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise { + async prContext( + directory: string, + number: number, + options?: { includeDiff?: boolean; includeCheckDetails?: boolean } + ): Promise { const url = new URL('/api/github/pulls/context', window.location.origin); url.searchParams.set('directory', directory); url.searchParams.set('number', String(number)); if (options?.includeDiff) { url.searchParams.set('diff', '1'); } + if (options?.includeCheckDetails) { + url.searchParams.set('checkDetails', '1'); + } const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }); const body = await jsonOrNull(response); if (!response.ok || !body) {