@saadnvd1/agent-os 0.3.502 → 0.3.503
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +2 -0
- package/components/PrStageBadge.tsx +69 -0
- package/components/SessionList/SessionList.types.ts +2 -0
- package/components/SessionList/SessionRow.tsx +2 -0
- package/components/views/types.ts +2 -0
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/checks.ts +2 -0
- package/lib/orchestrator/facts.ts +1 -0
- package/lib/orchestrator/task-state.ts +30 -7
- package/lib/sidebar/shelves.ts +5 -0
- package/lib/status/collect.ts +6 -0
- package/lib/status/pr-stages.ts +55 -0
- package/lib/tasks/pr-stage.ts +92 -0
- package/lib/tasks/session.ts +13 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SessionPrStage } from "@/components/PrStageBadge";
|
|
3
4
|
import { ViewSwitch } from "@/components/Chat/ViewSwitch";
|
|
4
5
|
import { ContextMeter } from "@/components/Chat/ContextMeter";
|
|
5
6
|
import { Button } from "@/components/ui/button";
|
|
@@ -155,6 +156,7 @@ export function DesktopTabBar({
|
|
|
155
156
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
156
157
|
<HostBadge hostId={session?.host_id} />
|
|
157
158
|
<ContextMeter sessionId={session?.id} />
|
|
159
|
+
<SessionPrStage sessionId={session?.id} />
|
|
158
160
|
<ViewSwitch session={session} />
|
|
159
161
|
{session?.working_directory && (
|
|
160
162
|
<PaneViewToggle
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SessionPrStage } from "@/components/PrStageBadge";
|
|
3
4
|
import { ViewSwitch } from "@/components/Chat/ViewSwitch";
|
|
4
5
|
import { paletteActions } from "@/stores/palette";
|
|
5
6
|
import { newDraft } from "@/stores/drafts";
|
|
@@ -317,6 +318,7 @@ export function MobileTabBar({
|
|
|
317
318
|
>
|
|
318
319
|
<Plus className="h-4 w-4" />
|
|
319
320
|
</Button>
|
|
321
|
+
<SessionPrStage sessionId={session?.id} />
|
|
320
322
|
<ViewSwitch session={session} />
|
|
321
323
|
|
|
322
324
|
{/* View mode toggle */}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { statusKeys } from "@/data/sessions/keys";
|
|
6
|
+
import {
|
|
7
|
+
PR_STAGE_BUSY,
|
|
8
|
+
prStageText,
|
|
9
|
+
type PrStage,
|
|
10
|
+
type PrStageName,
|
|
11
|
+
} from "@/lib/tasks/pr-stage";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
const TONE: Record<PrStageName, string> = {
|
|
15
|
+
merged: "bg-purple-500/15 text-purple-700 dark:text-purple-400",
|
|
16
|
+
conflicts: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
17
|
+
"ci-failed": "bg-red-500/15 text-red-700 dark:text-red-400",
|
|
18
|
+
"ci-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
|
19
|
+
"ci-settling": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
|
20
|
+
"review-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
|
21
|
+
"review-blocked": "bg-red-500/15 text-red-700 dark:text-red-400",
|
|
22
|
+
waiting: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
23
|
+
armed: "bg-green-500/15 text-green-700 dark:text-green-400",
|
|
24
|
+
open: "bg-muted text-muted-foreground",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Where a task's PR is in the gates, linking to the PR. `compact` drops the
|
|
28
|
+
// "PR #n" for the sidebar row's tight space (the number stays in the title).
|
|
29
|
+
export function PrStageBadge({
|
|
30
|
+
stage,
|
|
31
|
+
compact = false,
|
|
32
|
+
}: {
|
|
33
|
+
stage?: PrStage;
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
}) {
|
|
36
|
+
if (!stage) return null;
|
|
37
|
+
const text = prStageText(stage);
|
|
38
|
+
return (
|
|
39
|
+
<a
|
|
40
|
+
href={stage.url}
|
|
41
|
+
target="_blank"
|
|
42
|
+
rel="noreferrer"
|
|
43
|
+
title={`${text}${stage.sha ? ` · ${stage.sha}` : ""}`}
|
|
44
|
+
aria-label={text}
|
|
45
|
+
onClick={(e) => e.stopPropagation()}
|
|
46
|
+
className={cn(
|
|
47
|
+
"inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold whitespace-nowrap hover:underline",
|
|
48
|
+
TONE[stage.stage]
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{PR_STAGE_BUSY.has(stage.stage) && (
|
|
52
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
53
|
+
)}
|
|
54
|
+
{compact ? text.replace("PR ", "") : text}
|
|
55
|
+
</a>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The badge for one session, read from the status the sidebar already holds.
|
|
60
|
+
export function SessionPrStage({ sessionId }: { sessionId?: string | null }) {
|
|
61
|
+
const { data } = useQuery<{
|
|
62
|
+
statuses: Record<string, { prStage?: PrStage }>;
|
|
63
|
+
}>({ queryKey: statusKeys.all, enabled: false });
|
|
64
|
+
return (
|
|
65
|
+
<PrStageBadge
|
|
66
|
+
stage={sessionId ? data?.statuses[sessionId]?.prStage : undefined}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
1
2
|
import type { SessionNeed } from "@/lib/sidebar/shelves";
|
|
2
3
|
|
|
3
4
|
export interface SessionStatus {
|
|
@@ -11,6 +12,7 @@ export interface SessionStatus {
|
|
|
11
12
|
unread?: boolean;
|
|
12
13
|
stalled?: boolean;
|
|
13
14
|
compacting?: { since: number; before?: number };
|
|
15
|
+
prStage?: PrStage;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface SessionListProps {
|
|
@@ -12,6 +12,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
|
|
|
12
12
|
import { useRowContext } from "./RowContext";
|
|
13
13
|
import { SessionRowMenu } from "./SessionRowMenu";
|
|
14
14
|
import { RowShell, type RowMenuKind } from "./RowShell";
|
|
15
|
+
import { PrStageBadge } from "@/components/PrStageBadge";
|
|
15
16
|
import { RowBadge, RowDot, RowRename } from "./RowParts";
|
|
16
17
|
import { RowSubtitle } from "./RowSubtitle";
|
|
17
18
|
import { OrchestratorAsks } from "./OrchestratorAsks";
|
|
@@ -96,6 +97,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
96
97
|
trailing={
|
|
97
98
|
<>
|
|
98
99
|
<RowBadge row={row} />
|
|
100
|
+
{!row.need && <PrStageBadge stage={row.status?.prStage} compact />}
|
|
99
101
|
{!row.need && <TimeAgo at={session.updated_at} />}
|
|
100
102
|
</>
|
|
101
103
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
1
2
|
import type { SessionNeed } from "@/lib/sidebar/shelves";
|
|
2
3
|
import type { Session } from "@/lib/db";
|
|
3
4
|
import type { ProjectWithDevServers } from "@/lib/projects";
|
|
@@ -15,6 +16,7 @@ export interface SessionStatus {
|
|
|
15
16
|
unread?: boolean;
|
|
16
17
|
// A chat summarising its context; status stays "running".
|
|
17
18
|
compacting?: { since: number; before?: number };
|
|
19
|
+
prStage?: PrStage;
|
|
18
20
|
claudeSessionId?: string | null;
|
|
19
21
|
// From a program's OSC 7501 report: untrusted, rendered as plain text.
|
|
20
22
|
detail?: string | null;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* database, so it survives a restart.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
15
16
|
import { db, type Session } from "../db";
|
|
16
17
|
import type { TaskPR } from "../tasks/state";
|
|
17
18
|
import { latestApproval } from "./ask-approvals";
|
|
@@ -37,6 +38,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
|
|
|
37
38
|
merge_armed_seen = NULL WHERE id = ?`
|
|
38
39
|
).run(sessionId);
|
|
39
40
|
looked.delete(sessionId);
|
|
41
|
+
notifyStatusChanged();
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
// The workspace a task's PR merges through when armed: its project's, with
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { db } from "../db";
|
|
8
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
8
9
|
|
|
9
10
|
// ci: when the check count on a commit last changed, for settling.
|
|
10
11
|
export type CheckKind = "review" | "scope" | "ci";
|
|
@@ -57,6 +58,7 @@ export function putCheck(row: {
|
|
|
57
58
|
row.status,
|
|
58
59
|
row.detail ?? null
|
|
59
60
|
);
|
|
61
|
+
notifyStatusChanged();
|
|
60
62
|
return getCheck(row.sessionId, row.sha, row.kind)!;
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -200,6 +200,7 @@ export async function statusOf(s: Session): Promise<{
|
|
|
200
200
|
|
|
201
201
|
// A commit's time never changes: read once per sha.
|
|
202
202
|
const committed = new Map<string, number>();
|
|
203
|
+
export const knownCommitTime = (sha: string) => committed.get(sha) ?? 0;
|
|
203
204
|
|
|
204
205
|
export async function settleIn(
|
|
205
206
|
workspaceId: string,
|
|
@@ -114,6 +114,20 @@ async function peerChatWaiting(
|
|
|
114
114
|
// check has registered on it for 2 minutes. Seconds still to wait.
|
|
115
115
|
export const SETTLE_S = 120;
|
|
116
116
|
|
|
117
|
+
// When the check count last changed, as ciSettleIn stored it.
|
|
118
|
+
const lastSeen = (detail: string | null | undefined) =>
|
|
119
|
+
detail ? (JSON.parse(detail) as { count: number; at: number }) : null;
|
|
120
|
+
|
|
121
|
+
const settleLeft = (
|
|
122
|
+
last: { count: number; at: number } | null,
|
|
123
|
+
checkCount: number,
|
|
124
|
+
committedAt: number,
|
|
125
|
+
now: number
|
|
126
|
+
) => {
|
|
127
|
+
const changedAt = last && last.count === checkCount ? last.at : now;
|
|
128
|
+
return Math.max(0, Math.max(committedAt, changedAt) + SETTLE_S - now);
|
|
129
|
+
};
|
|
130
|
+
|
|
117
131
|
export function ciSettleIn(input: {
|
|
118
132
|
workspaceId: string;
|
|
119
133
|
taskId: string;
|
|
@@ -123,11 +137,7 @@ export function ciSettleIn(input: {
|
|
|
123
137
|
now?: number;
|
|
124
138
|
}): number {
|
|
125
139
|
const now = Math.floor((input.now ?? Date.now()) / 1000);
|
|
126
|
-
const
|
|
127
|
-
const last = seen?.detail
|
|
128
|
-
? (JSON.parse(seen.detail) as { count: number; at: number })
|
|
129
|
-
: null;
|
|
130
|
-
const changedAt = last && last.count === input.checkCount ? last.at : now;
|
|
140
|
+
const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
|
|
131
141
|
if (!last || last.count !== input.checkCount)
|
|
132
142
|
putCheck({
|
|
133
143
|
workspaceId: input.workspaceId,
|
|
@@ -137,8 +147,21 @@ export function ciSettleIn(input: {
|
|
|
137
147
|
status: "pass",
|
|
138
148
|
detail: JSON.stringify({ count: input.checkCount, at: now }),
|
|
139
149
|
});
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
return settleLeft(last, input.checkCount, input.committedAt, now);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The same answer from what ciSettleIn stored, writing nothing: for a
|
|
154
|
+
// status that only looks.
|
|
155
|
+
export function ciSettleLeft(input: {
|
|
156
|
+
taskId: string;
|
|
157
|
+
sha: string;
|
|
158
|
+
checkCount: number;
|
|
159
|
+
committedAt: number;
|
|
160
|
+
now?: number;
|
|
161
|
+
}): number {
|
|
162
|
+
const now = Math.floor((input.now ?? Date.now()) / 1000);
|
|
163
|
+
const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
|
|
164
|
+
return settleLeft(last, input.checkCount, input.committedAt, now);
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
// A head with no checks for this long after its PR became mergeable, in a
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
|
|
3
3
|
import type { Session } from "@/lib/db/types";
|
|
4
4
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
5
|
+
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
5
6
|
import type { TaskState } from "@/lib/tasks/state";
|
|
6
7
|
|
|
7
8
|
// Why a session is blocked on you.
|
|
@@ -33,6 +34,7 @@ export interface RowStatus {
|
|
|
33
34
|
// A chat summarising its context (status stays "running").
|
|
34
35
|
compacting?: { since: number; before?: number };
|
|
35
36
|
asks?: number;
|
|
37
|
+
prStage?: PrStage;
|
|
36
38
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
37
39
|
detail?: string | null;
|
|
38
40
|
}
|
|
@@ -264,6 +266,9 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
|
|
|
264
266
|
(a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
|
|
265
267
|
(a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
|
|
266
268
|
a.asks === b.asks &&
|
|
269
|
+
(a.prStage?.stage ?? null) === (b.prStage?.stage ?? null) &&
|
|
270
|
+
(a.prStage?.number ?? null) === (b.prStage?.number ?? null) &&
|
|
271
|
+
(a.prStage?.sha ?? null) === (b.prStage?.sha ?? null) &&
|
|
267
272
|
(a.detail ?? null) === (b.detail ?? null)
|
|
268
273
|
);
|
|
269
274
|
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
terminalStatus,
|
|
31
31
|
} from "../needs-you";
|
|
32
32
|
import type { SessionNeed } from "../sidebar/shelves";
|
|
33
|
+
import type { PrStage } from "../tasks/pr-stage";
|
|
34
|
+
import { addPrStages } from "./pr-stages";
|
|
33
35
|
import type { Compacting } from "../chat/events";
|
|
34
36
|
import { openAskCount } from "../orchestrator/asks";
|
|
35
37
|
import { lastUserTask } from "../chat/store";
|
|
@@ -65,6 +67,8 @@ export interface SessionStatusResponse {
|
|
|
65
67
|
stalled?: boolean;
|
|
66
68
|
// A chat whose agent is summarising its context ("status" stays running).
|
|
67
69
|
compacting?: Compacting;
|
|
70
|
+
// Where a task's PR is in the gates (lib/tasks/pr-stage.ts).
|
|
71
|
+
prStage?: PrStage;
|
|
68
72
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
69
73
|
detail?: string | null;
|
|
70
74
|
progress?: number | null;
|
|
@@ -477,6 +481,8 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
477
481
|
};
|
|
478
482
|
}
|
|
479
483
|
|
|
484
|
+
addPrStages(statusMap);
|
|
485
|
+
|
|
480
486
|
// Cleanup old trackers
|
|
481
487
|
statusDetector.cleanup();
|
|
482
488
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A task's PR stage for the sidebar, from state AgentOS already holds: the
|
|
2
|
+
// PR as the last poll saw it, the stored review and CI checks, escalations,
|
|
3
|
+
// asks and the armed flag. Nothing here asks GitHub.
|
|
4
|
+
import { db, type Session } from "../db";
|
|
5
|
+
import { cachedPR, storedPR } from "../tasks/session";
|
|
6
|
+
import { derivePrStage } from "../tasks/pr-stage";
|
|
7
|
+
import { getCheck } from "../orchestrator/checks";
|
|
8
|
+
import { holdingEscalations } from "../orchestrator/gates";
|
|
9
|
+
import { openBySubject, workSubject } from "../orchestrator/asks";
|
|
10
|
+
import { ciSettleLeft } from "../orchestrator/task-state";
|
|
11
|
+
import { knownCommitTime } from "../orchestrator/facts";
|
|
12
|
+
import type { SessionStatusResponse } from "./collect";
|
|
13
|
+
|
|
14
|
+
type TaskRow = Session & { workspace_id: string };
|
|
15
|
+
|
|
16
|
+
export function addPrStages(
|
|
17
|
+
statuses: Record<string, SessionStatusResponse>
|
|
18
|
+
): void {
|
|
19
|
+
const tasks = db
|
|
20
|
+
.prepare(
|
|
21
|
+
`SELECT s.*, p.workspace_id FROM sessions s
|
|
22
|
+
JOIN projects p ON p.id = s.project_id
|
|
23
|
+
WHERE s.task_status = 'running' AND s.pr_number IS NOT NULL
|
|
24
|
+
AND s.archived_at IS NULL`
|
|
25
|
+
)
|
|
26
|
+
.all() as TaskRow[];
|
|
27
|
+
for (const t of tasks) {
|
|
28
|
+
const status = statuses[t.id];
|
|
29
|
+
if (!status) continue;
|
|
30
|
+
try {
|
|
31
|
+
const pr = cachedPR(t.id) ?? storedPR(t);
|
|
32
|
+
const head = pr?.head;
|
|
33
|
+
const stage = derivePrStage({
|
|
34
|
+
pr,
|
|
35
|
+
settleIn:
|
|
36
|
+
pr?.checks === "pass" && head
|
|
37
|
+
? ciSettleLeft({
|
|
38
|
+
taskId: t.id,
|
|
39
|
+
sha: head,
|
|
40
|
+
checkCount: pr.checkCount ?? 0,
|
|
41
|
+
committedAt: knownCommitTime(head),
|
|
42
|
+
})
|
|
43
|
+
: 0,
|
|
44
|
+
review: head ? (getCheck(t.id, head, "review")?.status ?? null) : null,
|
|
45
|
+
held:
|
|
46
|
+
holdingEscalations(t.id).length > 0 ||
|
|
47
|
+
!!openBySubject(t.workspace_id, workSubject(t.id)),
|
|
48
|
+
armed: !!t.merge_armed_at,
|
|
49
|
+
});
|
|
50
|
+
if (stage) status.prStage = stage;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`[status] PR stage of ${t.id.slice(0, 8)}:`, error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Where a task's PR is in the gates, as one word for the UI. Pure: the
|
|
2
|
+
// inputs are what AgentOS already tracks for the PR (tasks/state.ts,
|
|
3
|
+
// orchestrator/checks.ts), so nothing here asks GitHub or a model.
|
|
4
|
+
|
|
5
|
+
export const PR_STAGES = [
|
|
6
|
+
"merged",
|
|
7
|
+
"conflicts",
|
|
8
|
+
"ci-failed",
|
|
9
|
+
"ci-running",
|
|
10
|
+
"ci-settling",
|
|
11
|
+
"review-running",
|
|
12
|
+
"review-blocked",
|
|
13
|
+
"waiting",
|
|
14
|
+
"armed",
|
|
15
|
+
"open",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type PrStageName = (typeof PR_STAGES)[number];
|
|
19
|
+
|
|
20
|
+
export interface PrStage {
|
|
21
|
+
stage: PrStageName;
|
|
22
|
+
number: number;
|
|
23
|
+
url: string;
|
|
24
|
+
// The PR's head, 7 characters.
|
|
25
|
+
sha?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PrStageInput {
|
|
29
|
+
pr: {
|
|
30
|
+
number: number;
|
|
31
|
+
url: string;
|
|
32
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
33
|
+
checks: "pass" | "fail" | "pending" | "none";
|
|
34
|
+
head?: string;
|
|
35
|
+
mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
36
|
+
} | null;
|
|
37
|
+
// Seconds until CI on the head counts as settled (0 or unset: settled).
|
|
38
|
+
settleIn?: number;
|
|
39
|
+
// The newest review of the head commit.
|
|
40
|
+
review?: "running" | "pass" | "block" | "error" | null;
|
|
41
|
+
// Escalated to Saad, or an ask of his open on it.
|
|
42
|
+
held?: boolean;
|
|
43
|
+
armed?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// In priority order: the first that applies wins, so a conflict hides the
|
|
47
|
+
// CI behind it and a failed CI hides a review that would be moot.
|
|
48
|
+
export function derivePrStage(input: PrStageInput): PrStage | null {
|
|
49
|
+
const { pr } = input;
|
|
50
|
+
if (!pr || pr.state === "CLOSED") return null;
|
|
51
|
+
const base = {
|
|
52
|
+
number: pr.number,
|
|
53
|
+
url: pr.url,
|
|
54
|
+
...(pr.head && { sha: pr.head.slice(0, 7) }),
|
|
55
|
+
};
|
|
56
|
+
const at = (stage: PrStageName): PrStage => ({ stage, ...base });
|
|
57
|
+
if (pr.state === "MERGED") return at("merged");
|
|
58
|
+
if (pr.mergeable === "CONFLICTING") return at("conflicts");
|
|
59
|
+
if (pr.checks === "fail") return at("ci-failed");
|
|
60
|
+
if (pr.checks === "pending") return at("ci-running");
|
|
61
|
+
if (pr.checks === "pass" && (input.settleIn ?? 0) > 0)
|
|
62
|
+
return at("ci-settling");
|
|
63
|
+
if (input.review === "running") return at("review-running");
|
|
64
|
+
if (input.review === "block") return at("review-blocked");
|
|
65
|
+
if (input.held) return at("waiting");
|
|
66
|
+
if (input.armed) return at("armed");
|
|
67
|
+
return at("open");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stages still moving on their own: the badge spins for these.
|
|
71
|
+
export const PR_STAGE_BUSY: ReadonlySet<PrStageName> = new Set([
|
|
72
|
+
"conflicts",
|
|
73
|
+
"ci-running",
|
|
74
|
+
"ci-settling",
|
|
75
|
+
"review-running",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export const PR_STAGE_LABEL: Record<PrStageName, string> = {
|
|
79
|
+
merged: "merged",
|
|
80
|
+
conflicts: "rebasing",
|
|
81
|
+
"ci-failed": "CI failed",
|
|
82
|
+
"ci-running": "CI running",
|
|
83
|
+
"ci-settling": "CI settling",
|
|
84
|
+
"review-running": "reviewing",
|
|
85
|
+
"review-blocked": "review blocked",
|
|
86
|
+
waiting: "waiting on you",
|
|
87
|
+
armed: "armed",
|
|
88
|
+
open: "open",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const prStageText = (s: Pick<PrStage, "stage" | "number">) =>
|
|
92
|
+
`PR #${s.number} · ${PR_STAGE_LABEL[s.stage]}`;
|
package/lib/tasks/session.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { getProject } from "../projects";
|
|
|
9
9
|
import { findPRStrict } from "./gh";
|
|
10
10
|
import { currentBranch, git } from "../git-exec";
|
|
11
11
|
import { lookupPR } from "./pr-poll";
|
|
12
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
12
13
|
import type { TaskPR } from "./state";
|
|
13
14
|
|
|
14
15
|
export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
@@ -128,6 +129,15 @@ export const isoUTC = (t: string) =>
|
|
|
128
129
|
|
|
129
130
|
const prCache = new Map<string, { at: number; pr: TaskPR | null }>();
|
|
130
131
|
|
|
132
|
+
const prSignature = (pr: TaskPR | null | undefined) =>
|
|
133
|
+
pr
|
|
134
|
+
? `${pr.number}|${pr.state}|${pr.checks}|${pr.head}|${pr.mergeable}|${pr.checkCount}`
|
|
135
|
+
: "";
|
|
136
|
+
|
|
137
|
+
// The PR as the last poll saw it, with no gh call; undefined before one.
|
|
138
|
+
export const cachedPR = (sessionId: string): TaskPR | null | undefined =>
|
|
139
|
+
prCache.get(sessionId)?.pr;
|
|
140
|
+
|
|
131
141
|
// strict: a gh failure throws instead of reading as "no PR".
|
|
132
142
|
export async function prFor(
|
|
133
143
|
session: Session,
|
|
@@ -167,7 +177,10 @@ export async function prFor(
|
|
|
167
177
|
}
|
|
168
178
|
}
|
|
169
179
|
if (renamed && !refused) moveBranch(session, renamed);
|
|
180
|
+
const before = prCache.get(session.id)?.pr;
|
|
170
181
|
prCache.set(session.id, { at: Date.now(), pr });
|
|
182
|
+
// A PR whose stage inputs moved is news for the sidebar's badge.
|
|
183
|
+
if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
|
|
171
184
|
if (pr) {
|
|
172
185
|
db.prepare(
|
|
173
186
|
`UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
|