Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion apps/desktop/src/main/ipc/register-github-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { enhancePrdDraft, fetchProjectPriorities, GhCli } from '@shipcode/agents';
import {
enhancePrdDraft,
fetchProjectPriorities,
GhCli,
type IssueTriageRecommendation,
triageGitHubIssues,
} from '@shipcode/agents';
import type {
ExecutorModel,
GitHubIssueCacheRecord,
Expand Down Expand Up @@ -88,6 +94,23 @@ function syncOpenIssueState(
return queries.githubIssues.getByNumber(issue.projectId, issue.issueNumber);
}

function mergeTriageLabels(
currentLabels: string[],
recommendation: IssueTriageRecommendation,
): string[] {
const next = currentLabels.filter(
(label) =>
!label.startsWith('agent:') &&
!label.startsWith('complexity:') &&
!label.startsWith('blast:'),
);
const labels = [...recommendation.suggestedLabels];
if (recommendation.suggestedAgent && !labels.some((label) => label.startsWith('agent:'))) {
labels.push(`agent:${recommendation.suggestedAgent}`);
}
return Array.from(new Set([...next, ...labels]));
}

export function registerGitHubHandlers({
ipcMain,
mainWindow,
Expand Down Expand Up @@ -274,6 +297,97 @@ export function registerGitHubHandlers({
},
);

ipcMain.handle('github:triage-issues', async (_event, { projectId }: { projectId: string }) => {
const project = queries.projects.getById(projectId);
if (!project) throw new Error(`Project ${projectId} not found`);
if (!fs.existsSync(project.path)) {
throw new Error(
`Project path no longer exists: ${project.path}. Re-add the repository from a valid path.`,
);
}

const settings = queries.settings.get();
const ghCli = new GhCli(project.path);
const candidates = queries.githubIssues
.list(projectId)
.filter(
(issue) =>
issue.state === 'open' &&
issue.pipelineStatus === ISSUE_PIPELINE_STATUS.todo &&
!issue.threadId &&
!issue.isQuickMode &&
isRealGithubIssueNumber(issue.issueNumber),
);

if (candidates.length === 0) {
return {
provider: settings.triageModel,
modelId: settings.triageModelId,
resolvedModel: settings.triageModelId,
consideredCount: 0,
appliedCount: 0,
skippedCount: 0,
threshold: settings.triageAutoApplyThreshold,
issues: [],
};
}

await ghCli.ensureLabels(SHIPCODE_DEFAULT_LABELS);
const result = await triageGitHubIssues({
cwd: project.path,
issues: candidates,
settings,
apiKey: process.env.OPENROUTER_API_KEY,
});
const candidatesByNumber = new Map(candidates.map((issue) => [issue.issueNumber, issue]));
const threshold = settings.triageAutoApplyThreshold;
let appliedCount = 0;
const summaries = [];

for (const recommendation of result.recommendations) {
const issue = candidatesByNumber.get(recommendation.issueNumber);
if (!issue) continue;
const applied = recommendation.confidence >= threshold;
if (applied) {
const nextLabels = mergeTriageLabels(issue.labels, recommendation);
await ghCli.syncIssueLabels(issue.issueNumber, nextLabels, { removeAgentLabels: true });
const refreshedIssue = await ghCli.getIssue(issue.issueNumber);
queries.githubIssues.upsert({
projectId,
issueNumber: refreshedIssue.number,
title: refreshedIssue.title,
body: refreshedIssue.body,
labels: refreshedIssue.labels,
assignee: refreshedIssue.assignee,
state: refreshedIssue.state,
});
appliedCount += 1;
}
summaries.push({
issueNumber: recommendation.issueNumber,
confidence: recommendation.confidence,
applied,
suggestedLabels: recommendation.suggestedLabels,
suggestedAgent: recommendation.suggestedAgent,
shouldStart: recommendation.shouldStart,
needsHuman: recommendation.needsHuman,
rationale: recommendation.rationale,
});
}

sendGithubIssuesUpdated(mainWindow, queries, projectId);
return {
provider: result.provider,
modelId: result.modelId,
resolvedModel: result.resolvedModel,
consideredCount: candidates.length,
appliedCount,
skippedCount: Math.max(0, summaries.length - appliedCount),
threshold,
issues: summaries,
};
});

ipcMain.handle(
'github:archive-issue',
async (_event, { projectId, issueNumber }: { projectId: string; issueNumber: number }) => {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/renderer/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type AppSettings,
type GitHubIssueCacheRecord,
type GitHubIssueTriageResult,
githubRepoUrl,
ISSUE_PIPELINE_STATUS,
type IssuePipelineStatus,
Expand Down Expand Up @@ -154,6 +155,28 @@ export function ThreadPanel() {
},
});

const triageIssues = useMutation({
mutationFn: (projectId: string) =>
window.shipcode.invoke<GitHubIssueTriageResult>('github:triage-issues', { projectId }),
onSuccess: (result, projectId) => {
queryClient.invalidateQueries({ queryKey: ['github-issues', projectId] });
setArchiveFeedback({
tone: result.appliedCount > 0 ? 'success' : 'pending',
message:
result.consideredCount === 0
? 'No Todo issues need triage.'
: `Triaged ${result.consideredCount} issue${result.consideredCount === 1 ? '' : 's'}; applied ${result.appliedCount}.`,
});
},
onError: (err) => {
setArchiveFeedback({
tone: 'error',
message: `Issue triage failed: ${err instanceof Error ? err.message : String(err)}`,
});
log.error('[threadpanel] triage-issues failed', { err });
},
});

useEffect(() => {
if (!archiveFeedback || archiveFeedback.tone === 'pending') return;
const id = setTimeout(() => setArchiveFeedback(null), 5000);
Expand Down Expand Up @@ -410,6 +433,8 @@ export function ThreadPanel() {
}
selectedIssueNumber={selectedBoardIssueNumber}
onRefresh={() => activeProjectId && refreshIssues.mutate(activeProjectId)}
onTriageIssues={() => activeProjectId && triageIssues.mutate(activeProjectId)}
triagingIssues={triageIssues.isPending}
baseBranch={project?.defaultBranch}
branches={branches}
refreshingBranches={isRefreshingBranches}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ export function PipelineSettingsSection({
prdRewriteProvider,
prdRewriteModelValue,
);
const triageModelOptions = getModelOptions(settings.triageModel, integrationStatus);
const triageKnownModelValues = new Set(triageModelOptions.map((option) => option.value));
const triageEffortResolution = resolveProviderReasoningEffort(
settings.triageModel,
settings.triageReasoningEffort,
settings.triageModelId,
);
const triageSupportedEfforts =
settings.triageModel === 'openrouter'
? (['none', 'low', 'medium', 'high'] as const)
: getCapabilitySupportedReasoningEfforts(
integrationStatus,
settings.triageModel,
settings.triageModelId,
);
const normalizeEffort = (
provider: ExecutorModel,
effort: AppSettings['plannerReasoningEffort'],
Expand Down Expand Up @@ -409,6 +424,122 @@ export function PipelineSettingsSection({
</div>
</div>

<div className="mb-5 rounded-md border border-border bg-secondary/40 p-3">
<div className="mb-3">
<div className="text-[13px] font-medium text-primary">Issue triage</div>
<div className="text-[11px] text-muted">
Board review model for classifying Todo issues and applying high-confidence
labels.
</div>
</div>
<SettingsRow label="Triage provider" htmlFor="triage-provider">
<Select
value={settings.triageModel}
onValueChange={(value) =>
onUpdate({
triageModel: value as AppSettings['triageModel'],
triageModelId: null,
triageReasoningEffort: normalizeEffort(
value as ExecutorModel,
settings.triageReasoningEffort,
null,
),
})
}
>
<SelectTrigger id="triage-provider" className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Anthropic</SelectItem>
<SelectItem value="codex">OpenAI</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow label="Triage model" htmlFor="triage-model">
<Select
value={settings.triageModelId ?? '__default__'}
onValueChange={(value) => {
const nextModelId = value === '__default__' ? null : value;
onUpdate({
triageModelId: nextModelId,
triageReasoningEffort: normalizeEffort(
settings.triageModel,
settings.triageReasoningEffort,
nextModelId,
),
});
}}
>
<SelectTrigger id="triage-model" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default small model</SelectItem>
{settings.triageModelId &&
!triageKnownModelValues.has(settings.triageModelId) ? (
<SelectItem value={settings.triageModelId}>
{settings.triageModelId}
</SelectItem>
) : null}
{triageModelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow
label={
settings.triageModel === 'claude'
? 'Triage thinking budget'
: 'Triage reasoning effort'
}
htmlFor="triage-reasoning"
>
<Select
value={triageEffortResolution.effective}
onValueChange={(value) =>
onUpdate({
triageReasoningEffort: value as AppSettings['triageReasoningEffort'],
})
}
>
<SelectTrigger id="triage-reasoning" className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{triageSupportedEfforts.map((effort) => (
<SelectItem key={effort} value={effort}>
{formatReasoningEffortLabel(effort as AppSettings['triageReasoningEffort'])}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow
label="Auto-apply threshold"
htmlFor="triage-threshold"
description="Recommendations below this confidence are reported but not applied."
>
<Input
id="triage-threshold"
type="number"
className="w-[90px]"
min={0}
max={1}
step={0.05}
value={settings.triageAutoApplyThreshold}
onChange={(event) => {
const value = Number(event.target.value);
if (value >= 0 && value <= 1) onUpdate({ triageAutoApplyThreshold: value });
}}
/>
</SettingsRow>
</div>

<PhaseModelRow
label="Planner model"
htmlFor="planner-model"
Expand Down
15 changes: 11 additions & 4 deletions packages/agents/src/github/gh-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ export class GhCli {
);
}

private async syncIssueLabels(issueNumber: number, labels: string[]): Promise<void> {
async syncIssueLabels(
issueNumber: number,
labels: string[],
options?: { removeAgentLabels?: boolean },
): Promise<void> {
const next = await this.filterExistingLabels(labels);
let current: string[] = [];

Expand All @@ -183,9 +187,12 @@ export class GhCli {
current = [];
}

const toRemove = current.filter(
(label) => this.isManagedPrdLabel(label) && !next.includes(label),
);
const toRemove = current.filter((label) => {
const managed =
this.isManagedPrdLabel(label) ||
(!!options?.removeAgentLabels && label.startsWith('agent:'));
return managed && !next.includes(label);
});
const toAdd = next.filter((label) => !current.includes(label));

for (const label of toRemove) {
Expand Down
Loading
Loading