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:
Generated
+1
-1
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user