@saadnvd1/agent-os 0.3.474 → 0.3.477
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/README.md +20 -14
- package/app/api/away/settings/route.ts +26 -0
- package/components/Away/AwayWindowForm.tsx +91 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/away/index.ts +36 -0
- package/lib/away/activity.ts +147 -0
- package/lib/away/brief.ts +12 -4
- package/lib/away/report.ts +23 -9
- package/lib/away/run.ts +180 -62
- package/lib/away/state.ts +17 -0
- package/lib/away/sweep.ts +84 -70
- package/lib/away/window-limits.ts +16 -0
- package/lib/chat/events.ts +3 -0
- package/lib/done/worktree.ts +9 -2
- package/lib/models/roles.ts +1 -1
- package/lib/orchestrator/ask-approvals.ts +11 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +1 -1
- package/lib/orchestrator/events.ts +3 -0
- package/lib/orchestrator/rebase.ts +7 -1
- package/lib/orchestrator/repo.ts +78 -13
- package/lib/orchestrator/review.ts +18 -0
- package/lib/orchestrator/signoff.ts +9 -0
- package/lib/stacks/guard.ts +11 -0
- package/lib/tasks/finish.ts +7 -0
- package/lib/tasks/gh.ts +3 -1
- package/lib/tasks/pr-poll.ts +1 -1
- package/lib/tasks/session.ts +63 -2
- package/lib/tasks/state.ts +3 -0
- package/package.json +1 -1
package/lib/done/worktree.ts
CHANGED
|
@@ -142,8 +142,15 @@ async function commitsKeep(
|
|
|
142
142
|
return `${stray} commit${stray === 1 ? " is" : "s are"} on it that the merged PR didn't include and no remote has`;
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
// A base gone everywhere (a stacked parent's branch, deleted) is judged
|
|
146
|
+
// against the default branch instead.
|
|
147
|
+
const fallback = await getDefaultBranch(repo);
|
|
148
|
+
let base = session.base_branch || fallback;
|
|
149
|
+
let own = await ownCommits(path, base).catch(() => null);
|
|
150
|
+
if (own === null && base !== fallback) {
|
|
151
|
+
base = fallback;
|
|
152
|
+
own = await ownCommits(path, base).catch(() => null);
|
|
153
|
+
}
|
|
147
154
|
if (own === null) return `its base ${base} can't be found to compare`;
|
|
148
155
|
if (own > 0)
|
|
149
156
|
return `its branch has ${own} commit${own === 1 ? "" : "s"} not merged into ${base}`;
|
package/lib/models/roles.ts
CHANGED
|
@@ -93,3 +93,14 @@ export function voidApprovals(workspaceId: string, subject: string): number {
|
|
|
93
93
|
)
|
|
94
94
|
.run(workspaceId, subject).changes;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// The same, for a subject whose workspace the caller doesn't have at hand
|
|
98
|
+
// (a task's base moved under its approval: what was approved no longer is).
|
|
99
|
+
export function voidApprovalsOn(subject: string): number {
|
|
100
|
+
return db
|
|
101
|
+
.prepare(
|
|
102
|
+
`UPDATE orchestrator_asks SET used_at = datetime('now'), answer = 'approve (void)'
|
|
103
|
+
WHERE subject = ? AND status = 'approved' AND used_at IS NULL`
|
|
104
|
+
)
|
|
105
|
+
.run(subject).changes;
|
|
106
|
+
}
|
|
@@ -44,7 +44,8 @@ Merge only through the gates: \`arm\` or \`sign_off\`. \`sign_off\` merges a tas
|
|
|
44
44
|
- code-review: a passing review (the gate above) covers that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left, and the PR body carries no AI attribution. Tasks don't review themselves or write a "Code review" section any more: your review is the one review, so don't ask a task for one.
|
|
45
45
|
- blocked: no BLOCKED: line and no approval or question waiting in the task's chat or terminal.
|
|
46
46
|
- scope: the diff stays in the task's repository, adds no secrets, isn't only lockfiles, and, for a task from a card, a check against the card (run with the review) says it's within what the card asks.
|
|
47
|
-
- stack: a stacked task's parent has merged.
|
|
47
|
+
- stack: a stacked task's parent has merged, or was dropped and the PR no longer targets its branch.
|
|
48
|
+
- base: the PR targets the task's base. AgentOS follows GitHub when a PR is retargeted to the default branch or its stack parent's branch (reviews of the old base stop counting); any other base is refused.
|
|
48
49
|
|
|
49
50
|
PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: your \`review\` of that exact head is what counts, as for a task; a "Code review" section in its body is ignored. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
|
|
50
51
|
|
|
@@ -2,6 +2,7 @@ import type { ChatOrigin, ChatState } from "../chat/events";
|
|
|
2
2
|
import type { CompactFirst } from "../chat/stale";
|
|
3
3
|
import {
|
|
4
4
|
isAnswer,
|
|
5
|
+
isAwayEvent,
|
|
5
6
|
deliveriesSince,
|
|
6
7
|
lastDeliveredAt,
|
|
7
8
|
logDeliveries,
|
|
@@ -91,6 +92,7 @@ export async function deliverEvents(opts: {
|
|
|
91
92
|
kind: "event",
|
|
92
93
|
label: "AgentOS",
|
|
93
94
|
...(decided.length ? { decided } : {}),
|
|
95
|
+
...(pending.every((e) => isAwayEvent(e)) ? { away: true as const } : {}),
|
|
94
96
|
};
|
|
95
97
|
markDelivered(ids, now);
|
|
96
98
|
const logged = logDeliveries(
|
|
@@ -324,7 +324,7 @@ export function buildDigest(
|
|
|
324
324
|
): string {
|
|
325
325
|
const checks = checksBySession(workspaceId);
|
|
326
326
|
const failures = failuresBySession(workspaceId);
|
|
327
|
-
const away = awayDigestLine();
|
|
327
|
+
const away = awayDigestLine(workspaceId);
|
|
328
328
|
const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
|
|
329
329
|
const body = renderDigest(
|
|
330
330
|
[
|
|
@@ -40,6 +40,9 @@ const ONCE = "once:";
|
|
|
40
40
|
export const answerKey = (askId: number) => `ask:${askId}`;
|
|
41
41
|
export const isAnswer = (e: Pick<EventRow, "key">) =>
|
|
42
42
|
e.key.startsWith(`${ONCE}ask:`);
|
|
43
|
+
// Away mode's own one-off lines (lib/away/run) all start "away".
|
|
44
|
+
export const isAwayEvent = (e: Pick<EventRow, "key">) =>
|
|
45
|
+
e.key.startsWith(`${ONCE}away`);
|
|
43
46
|
|
|
44
47
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
45
48
|
|
|
@@ -23,9 +23,11 @@ import os from "os";
|
|
|
23
23
|
import path from "path";
|
|
24
24
|
import { db, type Session } from "../db";
|
|
25
25
|
import { sendMessage } from "../bus";
|
|
26
|
+
import { getDefaultBranch } from "../git";
|
|
26
27
|
import { hostLink } from "../hosts/remote-api";
|
|
27
28
|
import type { TaskPR } from "../tasks/state";
|
|
28
29
|
import { run } from "../tasks/gh";
|
|
30
|
+
import { isAllowedBase } from "../tasks/session";
|
|
29
31
|
import { git as runGit, gitFetch, revParse } from "../git-exec";
|
|
30
32
|
import { outputOf, type Runner } from "../stacks/git";
|
|
31
33
|
import { restackedHeads, stackNotice } from "../stacks/restack";
|
|
@@ -127,7 +129,11 @@ async function rebaseOne(
|
|
|
127
129
|
deps: RebaseDeps
|
|
128
130
|
): Promise<void> {
|
|
129
131
|
const repo = repoOf(task);
|
|
130
|
-
|
|
132
|
+
// The PR's base as GitHub has it now, not the one the task started on.
|
|
133
|
+
// Only a base AgentOS would judge the task against: a branch the task
|
|
134
|
+
// picked itself could carry commits nobody reviewed.
|
|
135
|
+
if (pr.base && !(await isAllowedBase(task, repo, pr.base))) return;
|
|
136
|
+
const branch = pr.base || task.base_branch || (await getDefaultBranch(repo));
|
|
131
137
|
// A failed fetch throws: nothing is judged against a stale base.
|
|
132
138
|
await gitFetch(repo, ["--quiet", "--", "origin", branch, task.branch_name!], {
|
|
133
139
|
timeout: 60000,
|
package/lib/orchestrator/repo.ts
CHANGED
|
@@ -7,8 +7,16 @@ import os from "os";
|
|
|
7
7
|
import path from "path";
|
|
8
8
|
import type { Session } from "../db";
|
|
9
9
|
import { SKILL_DIRS } from "../agents/skill-dirs";
|
|
10
|
+
import { getDefaultBranch } from "../git";
|
|
10
11
|
import { getProject } from "../projects";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
git,
|
|
14
|
+
gitFetch,
|
|
15
|
+
gitOk,
|
|
16
|
+
isGitError,
|
|
17
|
+
worktreeAdd,
|
|
18
|
+
worktreeRemove,
|
|
19
|
+
} from "../git-exec";
|
|
12
20
|
import { expandHome } from "../tasks/session";
|
|
13
21
|
|
|
14
22
|
export function repoOf(task: Session): string {
|
|
@@ -17,22 +25,79 @@ export function repoOf(task: Session): string {
|
|
|
17
25
|
return expandHome(project.working_directory);
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
// The task's base on origin, fetched along with its head.
|
|
28
|
+
// The task's base on origin, fetched along with its head. The base is the
|
|
29
|
+
// PR's own (prFor records GitHub's baseRefName on the task). A base gone
|
|
30
|
+
// from origin (a stacked parent's branch, deleted) falls back to the
|
|
31
|
+
// project's default branch, and so does one that can't be found at all, so
|
|
32
|
+
// a diff never names a missing revision.
|
|
21
33
|
export async function fetchRefs(
|
|
22
34
|
repo: string,
|
|
23
35
|
task: Pick<Session, "base_branch" | "branch_name">
|
|
24
36
|
): Promise<string> {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
const head = task.branch_name ? [task.branch_name] : [];
|
|
38
|
+
const recorded = task.base_branch || null;
|
|
39
|
+
if (recorded) {
|
|
40
|
+
const fetched = await fetchBranches(repo, [recorded, ...head]);
|
|
41
|
+
// A base gone on origin fails the fetch, so the head goes on its own.
|
|
42
|
+
if (fetched === "gone" && head.length)
|
|
43
|
+
requireFetched(await fetchBranches(repo, head));
|
|
44
|
+
requireFetched(fetched);
|
|
45
|
+
if (fetched !== "gone") {
|
|
46
|
+
const found = await existingRef(repo, recorded);
|
|
47
|
+
if (found) return found;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fallback = await getDefaultBranch(repo);
|
|
51
|
+
if (fallback !== recorded) {
|
|
52
|
+
requireFetched(
|
|
53
|
+
await fetchBranches(repo, recorded ? [fallback] : [fallback, ...head])
|
|
54
|
+
);
|
|
55
|
+
const found = await existingRef(repo, fallback);
|
|
56
|
+
if (found) return found;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Neither the base ${recorded ?? "(none recorded)"} nor the default branch ${fallback} can be found`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An unreachable origin leaves the tracking refs as they were, and a diff
|
|
64
|
+
// against a stale base can hide or invent changes, so the gates stop.
|
|
65
|
+
function requireFetched(result: "ok" | "gone" | "failed"): void {
|
|
66
|
+
if (result === "failed")
|
|
67
|
+
throw new Error("Couldn't fetch from origin, so the base can't be trusted");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "gone" when origin answered that the first branch isn't there; any other
|
|
71
|
+
// failure is "failed" (origin unreachable).
|
|
72
|
+
async function fetchBranches(
|
|
73
|
+
repo: string,
|
|
74
|
+
branches: string[]
|
|
75
|
+
): Promise<"ok" | "gone" | "failed"> {
|
|
76
|
+
try {
|
|
77
|
+
// In English whatever the locale: the message is read below.
|
|
78
|
+
await gitFetch(repo, ["--quiet", "--", "origin", ...branches], {
|
|
79
|
+
env: { LC_ALL: "C" },
|
|
80
|
+
});
|
|
81
|
+
return "ok";
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const stderr = isGitError(error) ? error.stderr : "";
|
|
84
|
+
return stderr.includes(`couldn't find remote ref ${branches[0]}`)
|
|
85
|
+
? "gone"
|
|
86
|
+
: "failed";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// origin/<branch> when it's there, else a local <branch>.
|
|
91
|
+
async function existingRef(
|
|
92
|
+
repo: string,
|
|
93
|
+
branch: string
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
for (const ref of [`origin/${branch}`, branch])
|
|
96
|
+
if (
|
|
97
|
+
await gitOk(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])
|
|
98
|
+
)
|
|
99
|
+
return ref;
|
|
100
|
+
return null;
|
|
36
101
|
}
|
|
37
102
|
|
|
38
103
|
// When the commit was made, in seconds.
|
|
@@ -65,6 +65,13 @@ export function describeReview(row: CheckRow): string {
|
|
|
65
65
|
// What a review reads: a task's PR, or a PR no task owns (external-pr.ts).
|
|
66
66
|
// `id` keys its stored checks; `task` is set only for a task, whose card
|
|
67
67
|
// gets a scope check.
|
|
68
|
+
const baseBranchOf = (id: string) =>
|
|
69
|
+
(
|
|
70
|
+
db.prepare(`SELECT base_branch FROM sessions WHERE id = ?`).get(id) as
|
|
71
|
+
| { base_branch: string | null }
|
|
72
|
+
| undefined
|
|
73
|
+
)?.base_branch ?? null;
|
|
74
|
+
|
|
68
75
|
export interface ReviewTarget {
|
|
69
76
|
id: string;
|
|
70
77
|
name: string;
|
|
@@ -85,6 +92,7 @@ async function reviewJob(
|
|
|
85
92
|
): Promise<CheckRow> {
|
|
86
93
|
const { pr, repo, task } = target;
|
|
87
94
|
const row = { workspaceId, sessionId: target.id, sha };
|
|
95
|
+
const judged = target.refs.base_branch;
|
|
88
96
|
const base = await fetchRefs(repo, target.refs);
|
|
89
97
|
const diff = await fullDiff(repo, base, sha);
|
|
90
98
|
const parts =
|
|
@@ -160,6 +168,16 @@ async function reviewJob(
|
|
|
160
168
|
});
|
|
161
169
|
verdicts.push(toVerdict(answer));
|
|
162
170
|
}
|
|
171
|
+
// A retarget while it ran (prFor records it): this verdict read the
|
|
172
|
+
// diff against the old base, so it covers nothing.
|
|
173
|
+
const now = task ? baseBranchOf(task.id) : judged;
|
|
174
|
+
if (now !== judged)
|
|
175
|
+
return putCheck({
|
|
176
|
+
...row,
|
|
177
|
+
kind: "review",
|
|
178
|
+
status: "error",
|
|
179
|
+
detail: `the PR's base moved from ${judged} to ${now} while it was reviewed; review it again`,
|
|
180
|
+
});
|
|
163
181
|
const done = putCheck({
|
|
164
182
|
...row,
|
|
165
183
|
kind: "review",
|
|
@@ -71,6 +71,15 @@ export async function judge(
|
|
|
71
71
|
const pr = await prFor(task, true);
|
|
72
72
|
if (!pr || pr.state !== "OPEN" || !pr.head)
|
|
73
73
|
return { ok: false, wait: false, text: `${task.name} has no open PR` };
|
|
74
|
+
// prFor records a retarget to the default branch or the stack parent's
|
|
75
|
+
// branch; any other base would merge somewhere nobody chose, judged
|
|
76
|
+
// against a branch the task picked.
|
|
77
|
+
if (pr.base && pr.base !== task.base_branch)
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
wait: false,
|
|
81
|
+
text: `${task.name}'s PR #${pr.number} targets ${pr.base}, not its base ${task.base_branch ?? "(none)"}: retarget it to ${task.base_branch ?? "the default branch"}`,
|
|
82
|
+
};
|
|
74
83
|
const sha = pr.head;
|
|
75
84
|
const repo = repoOf(task);
|
|
76
85
|
return gateVerdict(
|
package/lib/stacks/guard.ts
CHANGED
|
@@ -12,6 +12,17 @@ export function refusalFor(
|
|
|
12
12
|
): string | null {
|
|
13
13
|
if (!parent) return null;
|
|
14
14
|
const who = itemName(parent);
|
|
15
|
+
// A dropped parent never merges: once the PR moved off its branch
|
|
16
|
+
// (GitHub retargets it when the branch is deleted), there is nothing to
|
|
17
|
+
// wait for, and the review of the new base reads the parent's commits.
|
|
18
|
+
// A failed one can be retried, so it still blocks.
|
|
19
|
+
if (
|
|
20
|
+
parent.status === "dropped" &&
|
|
21
|
+
parentBranch &&
|
|
22
|
+
item.base_branch &&
|
|
23
|
+
item.base_branch !== parentBranch
|
|
24
|
+
)
|
|
25
|
+
return null;
|
|
15
26
|
if (parent.status !== "merged") {
|
|
16
27
|
const state =
|
|
17
28
|
parent.status === "pr" ? "has not merged" : `is ${parent.status}`;
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -127,9 +127,16 @@ export async function signOffTask(
|
|
|
127
127
|
if (!repo) throw new Error("Task has no project");
|
|
128
128
|
const refusal = signOffRefusal(id);
|
|
129
129
|
if (refusal) throw new Error(refusal);
|
|
130
|
+
// prFor follows a retarget onto the record, so compare with what the
|
|
131
|
+
// checks above were judged against.
|
|
132
|
+
const judgedBase = session.base_branch;
|
|
130
133
|
const pr = await prFor(session, true);
|
|
131
134
|
const verdict = canSignOff(pr);
|
|
132
135
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
136
|
+
if (pr!.base && pr!.base !== judgedBase)
|
|
137
|
+
throw new Error(
|
|
138
|
+
`PR #${pr!.number} targets ${pr!.base}, not its base ${judgedBase ?? "(none)"}: its review and checks were for the old base`
|
|
139
|
+
);
|
|
133
140
|
if (opts.head && pr!.head && pr!.head !== opts.head)
|
|
134
141
|
throw new Error(
|
|
135
142
|
`The PR moved to ${pr!.head.slice(0, 7)} after ${opts.head.slice(0, 7)} was checked`
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface ListedPR {
|
|
|
54
54
|
isCrossRepository?: boolean;
|
|
55
55
|
createdAt?: string;
|
|
56
56
|
mergeable?: TaskPR["mergeable"];
|
|
57
|
+
baseRefName?: string;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface FindPROpts {
|
|
@@ -81,7 +82,7 @@ export async function findPRStrict(
|
|
|
81
82
|
"--limit",
|
|
82
83
|
"10",
|
|
83
84
|
"--json",
|
|
84
|
-
"number,url,state,headRefOid,statusCheckRollup,body,isCrossRepository,createdAt,mergeable",
|
|
85
|
+
"number,url,state,headRefOid,statusCheckRollup,body,isCrossRepository,createdAt,mergeable,baseRefName",
|
|
85
86
|
],
|
|
86
87
|
repoDir,
|
|
87
88
|
15000
|
|
@@ -118,6 +119,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
|
|
|
118
119
|
pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
|
|
119
120
|
scopeChange: parseScopeChange(pr.body),
|
|
120
121
|
...(pr.mergeable && { mergeable: pr.mergeable }),
|
|
122
|
+
...(pr.baseRefName && { base: pr.baseRefName }),
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
|
package/lib/tasks/pr-poll.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { TaskPR } from "./state";
|
|
|
9
9
|
export const OPEN_TTL_MS = 60_000;
|
|
10
10
|
export const BRANCH_TTL_MS = 5 * 60_000;
|
|
11
11
|
const OPEN_FIELDS =
|
|
12
|
-
"number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable";
|
|
12
|
+
"number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable,baseRefName";
|
|
13
13
|
|
|
14
14
|
interface OpenList {
|
|
15
15
|
at: number;
|
package/lib/tasks/session.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
|
-
import { db, queries, type Session } from "../db";
|
|
4
|
-
import { isBranchName } from "../git";
|
|
3
|
+
import { db, queries, stackQueries, type Session } from "../db";
|
|
4
|
+
import { getDefaultBranch, isBranchName } from "../git";
|
|
5
|
+
import { parentBranchOf } from "../stacks/guard";
|
|
6
|
+
import { voidApprovalsOn } from "../orchestrator/ask-approvals";
|
|
7
|
+
import { taskSubject } from "../orchestrator/asks";
|
|
5
8
|
import { getProject } from "../projects";
|
|
6
9
|
import { findPRStrict } from "./gh";
|
|
7
10
|
import { currentBranch, git } from "../git-exec";
|
|
@@ -170,9 +173,67 @@ export async function prFor(
|
|
|
170
173
|
`UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
|
|
171
174
|
).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
|
|
172
175
|
}
|
|
176
|
+
// Only an answer just read from GitHub: a cached one may predate a
|
|
177
|
+
// retarget AgentOS made itself (a restack).
|
|
178
|
+
if (fresh) await recordBase(session, repo, pr);
|
|
173
179
|
return pr;
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
// An open PR's base as GitHub has it now becomes the task's, in the row and
|
|
183
|
+
// in `session`, so every diff, the stack logic and the UI read the truth:
|
|
184
|
+
// the base recorded at the start goes stale when the PR is retargeted (a
|
|
185
|
+
// stacked parent's branch deleted, so GitHub moved it to main). Only the
|
|
186
|
+
// default branch or the stack parent's branch is taken: a base the task
|
|
187
|
+
// picked itself would choose what its diff is judged against, so it stays
|
|
188
|
+
// unrecorded and sign_off refuses it. Reviews and scope checks judged
|
|
189
|
+
// against the old base no longer count. A stack item on a merged parent
|
|
190
|
+
// keeps its base until the restack moves it, which is also what takes the
|
|
191
|
+
// parent's commits off it.
|
|
192
|
+
// The bases a task may be judged against: the default branch or its stack
|
|
193
|
+
// parent's branch (and what it already has).
|
|
194
|
+
export async function isAllowedBase(
|
|
195
|
+
session: Session,
|
|
196
|
+
repo: string,
|
|
197
|
+
base: string
|
|
198
|
+
): Promise<boolean> {
|
|
199
|
+
if (base === session.base_branch) return true;
|
|
200
|
+
const item = stackQueries.itemForSession(db, session.id);
|
|
201
|
+
const parent = item?.parent_item_id
|
|
202
|
+
? stackQueries.item(db, item.parent_item_id)
|
|
203
|
+
: null;
|
|
204
|
+
return (
|
|
205
|
+
base === parentBranchOf(parent) || base === (await getDefaultBranch(repo))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function recordBase(
|
|
210
|
+
session: Session,
|
|
211
|
+
repo: string,
|
|
212
|
+
pr: TaskPR | null
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
const base = pr?.state === "OPEN" ? pr.base : undefined;
|
|
215
|
+
if (!base || base === session.base_branch || !isBranchName(base)) return;
|
|
216
|
+
if (!(await isAllowedBase(session, repo, base))) return;
|
|
217
|
+
const item = stackQueries.itemForSession(db, session.id);
|
|
218
|
+
const parent = item?.parent_item_id
|
|
219
|
+
? stackQueries.item(db, item.parent_item_id)
|
|
220
|
+
: null;
|
|
221
|
+
db.transaction(() => {
|
|
222
|
+
db.prepare(`UPDATE sessions SET base_branch = ? WHERE id = ?`).run(
|
|
223
|
+
base,
|
|
224
|
+
session.id
|
|
225
|
+
);
|
|
226
|
+
db.prepare(
|
|
227
|
+
`DELETE FROM orchestrator_checks WHERE session_id = ? AND kind IN ('review', 'scope')`
|
|
228
|
+
).run(session.id);
|
|
229
|
+
// An approval was for the diff against the old base.
|
|
230
|
+
voidApprovalsOn(taskSubject(session.id));
|
|
231
|
+
if (item && item.base_branch !== base && parent?.status !== "merged")
|
|
232
|
+
stackQueries.updateItem(db, item.id, { base_branch: base });
|
|
233
|
+
})();
|
|
234
|
+
session.base_branch = base;
|
|
235
|
+
}
|
|
236
|
+
|
|
176
237
|
// A finished task's PR as the database last saw it: no gh call.
|
|
177
238
|
export function storedPR(s: Session): TaskPR | null {
|
|
178
239
|
if (!s.pr_number) return null;
|
package/lib/tasks/state.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface TaskPR {
|
|
|
30
30
|
// GitHub's word on merging it into its base: CONFLICTING once the base
|
|
31
31
|
// moved under it (UNKNOWN while GitHub works it out).
|
|
32
32
|
mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
33
|
+
// The branch it merges into, as GitHub has it now: it moves when the
|
|
34
|
+
// PR is retargeted (a stacked parent's branch deleted, say).
|
|
35
|
+
base?: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export type TaskState =
|