open-swe/ui/src/lib/api.ts
Johannes du Plessis 69148f54f5
feat: add PR trace resolution (#1612)
* feat: add PR trace resolution

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: inject reviewer trace context as JSON

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: address review on PR trace resolution

Use the documented LangSmith metadata filter syntax
(and(eq(metadata_key,...), eq(metadata_value,...))) instead of
has(metadata, '{...}'), which does not match runs — _list_thread_runs
was silently returning nothing. Bound full-text searches to a 90-day
window so they don't hit LangSmith's large-window rate limit.

Also folds in the best-effort branch->head-sha resolver (dropping the
weighted scoring/threshold + repo/file evidence + GitHub hydration),
sandbox JSON injection, and the admin "Resolve trace" dry-run endpoint.

The IDOR findings are moot: resolve_pr_to_threads/summarize_agent_session
were removed; resolution now runs deterministically from the trusted run
config with no model-controlled pr_url or thread_id.

* fix: scope branch trace search to the repo

Branch names like fix-tests aren't unique across repos (or older PRs) in
a shared tracing project, so an unscoped branch hit could resolve to an
unrelated thread and write its runs into the reviewer sandbox. Require
the repo slug to co-occur with the branch in matched runs; the full head
SHA stays unscoped since it is globally unique. Addresses open-swe review
on PR #1612.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-25 14:11:21 -07:00

828 lines
22 KiB
TypeScript

/**
* Typed client for the open-swe dashboard backend.
*
* All requests are sent with credentials so the httpOnly `osw_session`
* cookie set by the OAuth callback rides along on cross-origin calls.
*/
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
/\/$/,
""
)
if (!API_BASE && typeof window !== "undefined") {
console.warn("VITE_DASHBOARD_API_BASE_URL is not set")
}
const GITHUB_IMAGE_HOST_RE =
/^(?:www\.)?github\.com$|\.githubusercontent\.com$/i
/**
* Build an authenticated proxy URL for GitHub-hosted PR images. Private-repo
* attachments can't be loaded directly by the browser, so they're routed
* through the dashboard backend which holds the App token. Non-GitHub image
* URLs are returned unchanged.
*/
export function reviewImageProxyUrl(
owner: string,
repo: string,
number: number,
src: string
): string {
let parsed: URL
try {
parsed = new URL(src)
} catch {
return src
}
if (
parsed.protocol !== "https:" ||
!GITHUB_IMAGE_HOST_RE.test(parsed.hostname)
) {
return src
}
if (
/^(?:www\.)?github\.com$/i.test(parsed.hostname) &&
!parsed.pathname.startsWith("/user-attachments/")
) {
return src
}
const path = `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/image`
return `${API_BASE}/dashboard/api${path}?url=${encodeURIComponent(src)}`
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = "ApiError"
}
}
export function isGithubReauthError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false
if (error.status === 401) return true
return /github token|re-login required/i.test(error.message)
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
if (body?.detail)
message =
typeof body.detail === "string"
? body.detail
: JSON.stringify(body.detail)
} catch {
/* ignore */
}
throw new ApiError(res.status, message)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export interface PRTraceResolutionResult {
resolved: boolean
detail: string
project: string | null
thread_id: string | null
confidence: number | null
evidence: Array<string>
trace_url: string | null
run_count: number
first_turn: string | null
last_turn: string | null
}
export interface SessionUser {
login: string
email: string | null
avatar_url: string | null
is_admin: boolean
slack_oauth_enabled?: boolean
}
export interface ModelOption {
id: string
label: string
efforts: Array<string>
default_effort: string
supports_images: boolean
}
export interface OptionsPayload {
models: Array<ModelOption>
default_agent_model: string
default_agent_reasoning_effort: string
default_agent_subagent_model: string
default_agent_subagent_reasoning_effort: string
}
export interface Profile {
login?: string
email?: string
default_model?: string
reasoning_effort?: string
default_subagent_model?: string | null
subagent_reasoning_effort?: string | null
default_repo?: string | null
base_branch?: string | null
branch_prefix?: string | null
auto_fix_ci?: boolean
create_prs?: boolean
review_draft_prs?: boolean | null
updated_at?: string
}
export interface ProfileUpdate {
default_model: string
reasoning_effort: string
default_subagent_model?: string | null
subagent_reasoning_effort?: string | null
default_repo?: string | null
base_branch?: string | null
branch_prefix?: string | null
auto_fix_ci?: boolean
create_prs?: boolean
review_draft_prs?: boolean | null
}
export interface TeamSettings {
review_draft_prs: boolean
pr_summaries: boolean
review_trace_links: boolean
review_tracing_project?: string | null
org_guidelines?: string | null
default_agent_model?: string | null
default_agent_reasoning_effort?: string | null
default_agent_subagent_model?: string | null
default_agent_subagent_reasoning_effort?: string | null
default_repo?: string | null
default_reviewer_model?: string | null
default_reviewer_reasoning_effort?: string | null
default_reviewer_subagent_model?: string | null
default_reviewer_subagent_reasoning_effort?: string | null
default_grouping_model?: string | null
default_grouping_reasoning_effort?: string | null
default_chat_model?: string | null
default_chat_reasoning_effort?: string | null
updated_at?: string | null
}
export interface ProviderCredentialStatus {
connected: boolean
site?: string
endpoint?: string
api_key_last4?: string
updated_at?: string | null
}
export interface TeamCredentialsStatus {
datadog: ProviderCredentialStatus
langsmith: ProviderCredentialStatus
}
export interface DatadogConnectBody {
site: string
api_key: string
app_key: string
}
export interface LangSmithConnectBody {
api_key: string
endpoint?: string | null
}
export interface CurrentsCredentialStatus {
connected: boolean
api_key_last4?: string
updated_at?: string | null
}
export interface CurrentsConnectBody {
api_key: string
}
export interface NotionCredentialStatus {
connected: boolean
token_expires_at?: string | null
updated_at?: string | null
}
export interface UserMapping {
github_login: string
work_email: string
slack_user_id?: string | null
source?: string
status?: string
created_at?: string
updated_at?: string
}
export interface UserMappingsPage {
items: Array<UserMapping>
total: number
page: number
page_size: number
}
export type UsageLeaderboardPeriod = "7d" | "30d" | "all"
export interface UsageLeaderboardRow {
rank: number
user: {
name: string
github_login: string | null
email: string | null
}
favorite_model: string
agent_runs: number
prs_opened: number
merged_prs: number
agent_loc: number
additions: number
deletions: number
}
export interface ReviewerStatsCounterRow {
name: string
count: number
}
export interface ReviewerStatsPayload {
period: UsageLeaderboardPeriod
reviewed_prs: number
prs_with_findings: number
findings_recorded: number
surfaced_findings: number
addressed_findings: number
resolved_after_update: number
dismissed_findings: number
unresolved_surfaced_findings: number
resolution_rate: number
human_replies: number
severity_counts: Record<string, number>
top_categories: Array<ReviewerStatsCounterRow>
generated_at_ms: number | null
}
export interface UsageLeaderboardPayload {
period: UsageLeaderboardPeriod
rows: Array<UsageLeaderboardRow>
total_members: number
current_user_rank: number | null
generated_at_ms: number | null
reviewer_stats: ReviewerStatsPayload
}
export interface Repository {
full_name: string
private: boolean
}
export interface Installation {
id: number
account: string | null
account_type: string | null
}
export interface ReposPayload {
installations: Array<Installation>
repositories: Array<Repository>
}
export type ReviewStyleStatus = "idle" | "running" | "completed" | "failed"
export interface ReviewStyle {
full_name: string
owner?: string
name?: string
status: ReviewStyleStatus
custom_prompt: string | null
analysis_summary: string | null
top_reviewers: Array<string>
prs_sampled: number
reviews_sampled: number
analysis_thread_id: string | null
analysis_run_id: string | null
error: string | null
created_by?: string
created_at?: string
updated_at?: string
}
export interface AgentInstructions {
full_name: string
owner?: string
name?: string
instructions: string
created_by?: string
created_at?: string
updated_at?: string
}
export type RepoSnapshotStatus = "none" | "building" | "ready" | "failed"
export interface RepoSnapshot {
full_name: string
owner?: string
name?: string
dockerfile: string
snapshot_id: string | null
snapshot_name: string | null
status: RepoSnapshotStatus
status_message: string | null
build_log: string | null
fs_capacity_bytes?: number
vcpus?: number
mem_bytes?: number
target?: string | null
build_args?: Record<string, string> | null
build_started_at?: string | null
last_built_at?: string | null
created_by?: string
created_at?: string
updated_at?: string
}
export interface RepoSnapshotUpdateBody {
dockerfile: string
fs_capacity_bytes?: number | null
vcpus?: number | null
mem_bytes?: number | null
target?: string | null
build_args?: Record<string, string> | null
}
export type FindingSeverity = "low" | "medium" | "high" | "critical"
export type FindingConfidence = "low" | "medium" | "high"
export type FindingStatus = "open" | "resolved" | "dismissed"
export type FindingGroup = "bug" | "investigate" | "informational"
export interface FindingInteraction {
kind: "human_reply" | "bot_reply"
author?: string
body?: string
created_at?: string
}
export interface ReviewFinding {
id: string
severity: FindingSeverity
confidence: FindingConfidence
category: string
title: string
description: string
suggestion: string | null
file: string
start_line: number | null
end_line: number | null
side: "LEFT" | "RIGHT"
in_diff: boolean
status: FindingStatus
outdated: boolean
resolution_note: string | null
diff_hunk: string | null
github_thread_resolved: boolean
github_review_comment_id: number | null
interactions: Array<FindingInteraction>
group: FindingGroup
}
export interface ReviewCommentCreate {
path: string
line: number
side: "LEFT" | "RIGHT"
body: string
start_line?: number | null
start_side?: "LEFT" | "RIGHT" | null
}
export interface ReviewCommentResult {
id: number
html_url: string
}
export interface PrReviewComment {
id: number
author: string
author_avatar_url: string
path: string
line: number | null
side: "LEFT" | "RIGHT"
body: string
html_url: string
created_at: string
is_open_swe: boolean
// Outdated: the line no longer appears in the current diff, so it can't render inline.
is_outdated: boolean
}
export interface ReviewCommentsPayload {
comments: Array<PrReviewComment>
}
export interface ReviewCounts {
open: number
resolved: number
dismissed: number
bugs: number
flags: number
}
export interface ReviewSummary {
thread_id: string
owner: string
repo: string
number: number
title: string
url: string
head_ref: string
base_ref: string
author: string
head_sha: string
watch: boolean
status: "running" | "error" | "idle"
counts: ReviewCounts
updated_at: string | null
full_name?: string
}
export interface ReviewListPayload {
reviews: Array<ReviewSummary>
page: number
has_more: boolean
}
export interface ReviewUserRef {
login: string
avatar_url?: string | null
}
export interface ReviewCheckRun {
name: string
status: string
conclusion: string | null
url: string | null
}
export interface ReviewPrDetails {
state: string
title: string
body: string
additions: number
deletions: number
changed_files: number
commits: number
head_sha: string
head_ref: string
base_ref: string
author: ReviewUserRef | null
assignees: Array<ReviewUserRef>
requested_reviewers: Array<ReviewUserRef>
labels: Array<{ name: string; color: string | null }>
}
export interface ReviewDiffGroup {
index: number
title: string
summary: string
files: Array<string>
}
export interface ReviewDetail extends ReviewSummary {
pr: ReviewPrDetails
checks: Array<ReviewCheckRun>
findings: Array<ReviewFinding>
diff_groups: Array<ReviewDiffGroup>
diff_groups_stale: boolean
}
export interface ReviewDiffFile {
path: string
previousPath: string | null
status: "added" | "removed" | "modified" | "renamed"
additions: number
deletions: number
originalContent: string
modifiedContent: string
unrenderable?: boolean
}
export interface ReviewDiffPayload {
files: Array<ReviewDiffFile>
total_additions: number
total_deletions: number
truncated: boolean
}
export interface ReviewChatMeta {
available: boolean
assistant_id: string
}
export interface ReviewChatThread {
thread_id: string
title: string
updated_at?: string | null
}
/**
* Absolute base URL for the PR chat's LangGraph StreamProvider. The SDK builds
* request URLs as `new URL(apiUrl + path)`, so this must be absolute — a
* same-origin base is promoted using the current origin.
*/
export function reviewChatApiBase(
owner: string,
repo: string,
number: number
): string {
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
if (/^https?:\/\//.test(path)) return path
if (typeof window !== "undefined") {
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`
}
return path
}
export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings"
export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical"
export interface ReviewerEvalConfig {
dataset_name: string
experiment_prefix: string
max_concurrency: number
langsmith_project: string
langgraph_url: string
assistant_id: string
model_id: string
reasoning_effort: string
score_mode: ReviewerEvalScoreMode
severity_threshold: ReviewerEvalSeverity
cap: number
}
export interface ReviewerEvalProgress {
completed: number
total: number | null
}
export interface ReviewerEvalStatus {
name: string
status: "idle" | "running" | "completed" | "failed"
run_name?: string
langsmith_project: string
limit: number | null
config_snapshot?: ReviewerEvalConfig
started_at: string | null
finished_at: string | null
created_by: string | null
pid: number | null
exit_code: number | null
experiment_url: string | null
error: string | null
log_tail: string | null
progress?: ReviewerEvalProgress | null
github_run_url?: string | null
trigger?: string | null
updated_at: string
}
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<OptionsPayload>("/options"),
profile: () => request<Profile>("/profile"),
saveProfile: (body: ProfileUpdate) =>
request<Profile>("/profile", { method: "PUT", body: JSON.stringify(body) }),
repos: () => request<ReposPayload>("/repos"),
listReviewStyles: () => request<Array<ReviewStyle>>("/review-styles"),
createReviewStyle: (full_name: string) =>
request<ReviewStyle>("/review-styles", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getReviewStyle: (full_name: string) =>
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}`),
saveReviewStylePrompt: (full_name: string, custom_prompt: string) =>
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify({ custom_prompt }),
}),
analyzeReviewStyle: (full_name: string) =>
request<ReviewStyle>(
`/review-styles/${encodeURIComponent(full_name)}/analyze`,
{
method: "POST",
}
),
cancelReviewStyle: (full_name: string) =>
request<ReviewStyle>(
`/review-styles/${encodeURIComponent(full_name)}/cancel`,
{
method: "POST",
}
),
deleteReviewStyle: (full_name: string) =>
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listAgentInstructions: () =>
request<Array<AgentInstructions>>("/agent-instructions"),
createAgentInstructions: (full_name: string) =>
request<AgentInstructions>("/agent-instructions", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getAgentInstructions: (full_name: string) =>
request<AgentInstructions>(
`/agent-instructions/${encodeURIComponent(full_name)}`
),
saveAgentInstructions: (full_name: string, instructions: string) =>
request<AgentInstructions>(
`/agent-instructions/${encodeURIComponent(full_name)}`,
{
method: "PUT",
body: JSON.stringify({ instructions }),
}
),
deleteAgentInstructions: (full_name: string) =>
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listRepoSnapshots: () => request<Array<RepoSnapshot>>("/repo-snapshots"),
createRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>("/repo-snapshots", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`),
getRepoSnapshotTemplate: (full_name: string) =>
request<{ dockerfile: string }>(
`/repo-snapshots/template?full_name=${encodeURIComponent(full_name)}`
),
saveRepoSnapshot: (full_name: string, body: RepoSnapshotUpdateBody) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
buildRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(
`/repo-snapshots/${encodeURIComponent(full_name)}/build`,
{ method: "POST" }
),
deleteRepoSnapshot: (full_name: string) =>
request<void>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) =>
request<TeamSettings>("/team-settings", {
method: "PUT",
body: JSON.stringify(body),
}),
getTeamCredentials: () => request<TeamCredentialsStatus>("/team-credentials"),
connectDatadog: (body: DatadogConnectBody) =>
request<TeamCredentialsStatus>("/team-credentials/datadog", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectDatadog: () =>
request<TeamCredentialsStatus>("/team-credentials/datadog", {
method: "DELETE",
}),
connectLangSmith: (body: LangSmithConnectBody) =>
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectLangSmith: () =>
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
method: "DELETE",
}),
getMyCurrentsStatus: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents"),
connectCurrents: (body: CurrentsConnectBody) =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectCurrents: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "DELETE",
}),
getMyNotionStatus: () =>
request<NotionCredentialStatus>("/my-credentials/notion"),
disconnectNotion: () =>
request<NotionCredentialStatus>("/my-credentials/notion", {
method: "DELETE",
}),
listEnabledReviewRepos: () =>
request<{ repos: Array<string> }>("/enabled-review-repos"),
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>
request<{ repos: Array<string> }>("/enabled-review-repos", {
method: "PUT",
body: JSON.stringify({ full_name, enabled }),
}),
usageLeaderboard: (period: UsageLeaderboardPeriod = "30d", limit = 10) =>
request<UsageLeaderboardPayload>(
`/agent-usage-leaderboard?period=${encodeURIComponent(period)}&limit=${limit}`
),
myMapping: () => request<Partial<UserMapping>>("/my-mapping"),
adminListUserMappings: (page = 1, pageSize = 20) =>
request<UserMappingsPage>(
`/admin/user-mappings?page=${page}&page_size=${pageSize}`
),
adminDeleteUserMapping: (github_login: string) =>
request<{ deleted: boolean }>(
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
{ method: "DELETE" }
),
listReviews: (page: number, mine: boolean) =>
request<ReviewListPayload>(`/reviews?page=${page}&mine=${mine}`),
getReview: (owner: string, repo: string, number: number) =>
request<ReviewDetail>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`
),
getReviewDiff: (owner: string, repo: string, number: number) =>
request<ReviewDiffPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`
),
getReviewChat: (owner: string, repo: string, number: number) =>
request<ReviewChatMeta>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
),
listReviewChatThreads: (owner: string, repo: string, number: number) =>
request<{ threads: Array<ReviewChatThread> }>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`
),
deleteReviewChatThread: (
owner: string,
repo: string,
number: number,
threadId: string
) =>
request<void>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
{ method: "DELETE" }
),
reReview: (owner: string, repo: string, number: number) =>
request<{
success: boolean
queued: boolean
thread_id: string
pr_url: string
}>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
{ method: "POST" }
),
resolveTrace: (owner: string, repo: string, number: number) =>
request<PRTraceResolutionResult>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/resolve-trace`,
{ method: "POST" }
),
createReviewComment: (
owner: string,
repo: string,
number: number,
body: ReviewCommentCreate
) =>
request<ReviewCommentResult>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments`,
{ method: "POST", body: JSON.stringify(body) }
),
listReviewComments: (owner: string, repo: string, number: number) =>
request<ReviewCommentsPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments`
),
getReviewerEval: () => request<ReviewerEvalStatus>("/admin/evals/reviewer"),
logout: () => request<void>("/auth/logout", { method: "POST" }),
}
export function loginUrl(redirectTo?: string): string {
const target =
redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "")
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
return `${API_BASE}/dashboard/api/auth/login${qs}`
}
export function slackConnectUrl(): string {
return `${API_BASE}/dashboard/api/slack/login`
}
export function notionConnectUrl(): string {
return `${API_BASE}/dashboard/api/notion/login`
}