feat: extend PR context to include check details

Add check details to PR context via includeCheckDetails flag
Open a checks dialog showing check run summaries and steps
Improve PR lookup for forked repos by matching head branch
This commit is contained in:
Bohdan Triapitsyn
2026-01-24 17:59:08 +02:00
parent b8f57484a5
commit 0e11ea3f83
14 changed files with 914 additions and 29 deletions
+1 -1
View File
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.5.5"
version = "1.5.6"
dependencies = [
"anyhow",
"axum",
@@ -82,6 +82,75 @@ pub struct GitHubPullRequestContextResult {
diff: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
checks: Option<GitHubChecksSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
check_runs: Option<Vec<GitHubCheckRun>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubCheckRun {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
app: Option<GitHubCheckRunApp>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conclusion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
details_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<GitHubCheckRunOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
job: Option<GitHubCheckRunJob>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubCheckRunApp {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
slug: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubCheckRunJob {
#[serde(skip_serializing_if = "Option::is_none")]
run_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
job_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conclusion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
steps: Option<Vec<GitHubCheckRunJobStep>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubCheckRunJobStep {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conclusion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
number: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubCheckRunOutput {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -540,10 +609,36 @@ struct CombinedStatusResponse {
#[derive(Debug, Deserialize)]
struct CheckRunEntry {
#[serde(default)]
name: Option<String>,
#[serde(default)]
app: Option<CheckRunApp>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
conclusion: Option<String>,
#[serde(default)]
details_url: Option<String>,
#[serde(default)]
output: Option<CheckRunOutput>,
}
#[derive(Debug, Deserialize)]
struct CheckRunApp {
#[serde(default)]
name: Option<String>,
#[serde(default)]
slug: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CheckRunOutput {
#[serde(default)]
title: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
text: Option<String>,
}
#[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::<Vec<Value>>(&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::<PullDetailsResponse>(&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<bool>,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestContextResult, String> {
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<GitHubChecksSummary> = None;
let mut check_runs_out: Option<Vec<GitHubCheckRun>> = None;
let include_check_details = includeCheckDetails.unwrap_or(false);
// actions jobs cache per run_id
let mut jobs_by_run_id: std::collections::HashMap<u64, Vec<Value>> = 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::<CheckRunsResponse>(&check_runs_url, &stored.access_token).await {
if !runs.check_runs.is_empty() {
let mut out: Vec<GitHubCheckRun> = 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<GitHubCheckRunJob> = 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::<u64>().ok()?;
let mut job_id_val: Option<u64> = None;
let next = iter.next().unwrap_or("");
if next == "job" {
job_id_val = iter.next().and_then(|s| s.parse::<u64>().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::<Value>(&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::<Vec<_>>()
});
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,
})
}
+6 -2
View File
@@ -85,11 +85,15 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
return safeInvoke<GitHubPullRequestsListResult>('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
},
async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult> {
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
): Promise<GitHubPullRequestContextResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestContextResult>(
'github_pr_context',
{ directory, number, includeDiff: Boolean(options?.includeDiff) },
{ directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails) },
{ timeout: 30000 }
);
},
@@ -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;
@@ -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<GitHubPullRequestContextResult | null>(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 (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">{run.name}</div>
<div className="typography-micro text-muted-foreground truncate">
{appName ? `${appName} · ${statusText}` : statusText}
</div>
{run.output?.summary ? (
<div className="typography-micro text-muted-foreground whitespace-pre-wrap line-clamp-4 mt-2">
{run.output.summary}
</div>
) : null}
{run.job?.steps && run.job.steps.length > 0 ? (
<div className="mt-2 space-y-1">
<div className="typography-micro text-muted-foreground">Steps</div>
<div className="space-y-1">
{run.job.steps.map((step, idx) => {
const c = (step.conclusion || '').toLowerCase();
const isFail = c && !['success', 'neutral', 'skipped'].includes(c);
return (
<div
key={`${step.name}-${idx}`}
className={
'typography-micro flex items-center gap-2 rounded px-2 py-1 ' +
(isFail ? 'bg-destructive/10 text-destructive' : 'text-muted-foreground')
}
>
<span className="truncate">{step.name}</span>
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
</div>
);
})}
</div>
</div>
) : null}
</div>
{run.detailsUrl ? (
<Button variant="outline" size="sm" asChild className="flex-shrink-0">
<a href={run.detailsUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open
</a>
</Button>
) : null}
</div>
);
}, []);
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}` : ''}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{checks ? (
<Button
variant="outline"
size="sm"
onClick={openChecksDialog}
disabled={isLoadingCheckDetails}
>
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Check details
</Button>
) : null}
{checks?.failure ? (
<Button variant="outline" size="sm" onClick={sendFailedChecksToChat}>
Send failed checks to chat
</Button>
) : null}
<Button variant="outline" size="sm" onClick={sendCommentsToChat}>
Send PR comments to chat
</Button>
</div>
{canMerge && pr.draft ? (
<div className="typography-micro text-muted-foreground">
Draft PRs must be marked ready before merge.
@@ -450,6 +700,50 @@ export const PullRequestSection: React.FC<{
</div>
</div>
</CollapsibleContent>
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" />
Check Details
</DialogTitle>
<DialogDescription>
{pr ? `PR #${pr.number}` : 'Pull request'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-2">
{isLoadingCheckDetails ? (
<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...
</div>
) : null}
{!isLoadingCheckDetails ? (
<div className="space-y-3">
{Array.isArray(checkDetails?.checkRuns) && checkDetails?.checkRuns.length > 0 ? (
checkDetails.checkRuns.map((run, idx) => (
<div key={`${run.name}-${idx}`} className="rounded-md border border-border/60 p-3">
{renderCheckRunSummary(run)}
</div>
))
) : (
<div className="text-center text-muted-foreground py-8">No check details available.</div>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex-1" />
<Button variant="outline" onClick={() => setChecksDialogOpen(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</Collapsible>
);
};
+31 -1
View File
@@ -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<GitHubPullRequestReadyResult>;
prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult>;
prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult>;
prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
): Promise<GitHubPullRequestContextResult>;
issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult>;
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
+5 -3
View File
@@ -340,11 +340,12 @@ interface MessageState {
sessionAbortFlags: Map<string, SessionAbortRecord>;
pendingAssistantHeaderSessions: Set<string>;
pendingUserMessageMetaBySession: Map<string, { mode?: string; providerID?: string; modelID?: string; variant?: string }>;
}
interface MessageActions {
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
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<void>;
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<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_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<MessageStore>()(
});
},
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<MessageStore>()(
// 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<MessageStore>()(
agent,
variant,
files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload,
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
});
+2 -1
View File
@@ -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);
}
+26 -3
View File
@@ -148,11 +148,34 @@ export const getPullRequestStatus = async (
return { connected: false };
}
const list = await jsonOrNull<Array<{ number: number }>>(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<Array<JsonRecord>>(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 };
+143 -6
View File
@@ -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<GitHubChecksSummary | null> => {
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<JsonRecord>(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<JsonRecord>(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<GitHubPullRequestContextResult> => {
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<number, JsonRecord[]>();
const runIds = new Set<number>();
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<JsonRecord>(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 };
};
+7 -2
View File
@@ -46,6 +46,11 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
prsList: async (directory: string, options?: { page?: number }) =>
sendBridgeMessage<GitHubPullRequestsListResult>('api:github/pulls:list', { directory, page: options?.page ?? 1 }),
prContext: async (directory: string, number: number, options?: { includeDiff?: boolean }) =>
sendBridgeMessage<GitHubPullRequestContextResult>('api:github/pulls:context', { directory, number, includeDiff: Boolean(options?.includeDiff) }),
prContext: async (directory: string, number: number, options?: { includeDiff?: boolean; includeCheckDetails?: boolean }) =>
sendBridgeMessage<GitHubPullRequestContextResult>('api:github/pulls:context', {
directory,
number,
includeDiff: Boolean(options?.includeDiff),
includeCheckDetails: Boolean(options?.includeCheckDetails),
}),
});
+115 -1
View File
@@ -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) {
+8 -1
View File
@@ -140,13 +140,20 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
return body;
},
async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult> {
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
): Promise<GitHubPullRequestContextResult> {
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<GitHubPullRequestContextResult & { error?: string }>(response);
if (!response.ok || !body) {