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
16 changes: 9 additions & 7 deletions .agents/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ shows the PR is dirty/conflicting). If the branch is merely behind but cleanly
mergeable, skip the rebase and let the merge handle it — needless rebases burn
iterations and CI.

**Bot pings by iteration.** First push (Phase 0 / initial PR) → `@copilot review
but do not make fixes`. Subsequent fix-iteration re-pushes → `@codex review`. For
a >250-file diff, also ping `@greptile` and `@coderabbit` (separate comments).
Phase 1 still waits for the review signal to settle before fixing. This is the
playbook's Phase 4 rule — defer to it for exact bodies.
**Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as
an expected review signal; quota exhaustion otherwise leaves the loop waiting
forever. Initial PR pushes do not need a direct review ping. Subsequent
fix-iteration re-pushes → `@codex review`. For a >250-file diff, also ping
`@greptile` and `@coderabbit` (separate comments). Phase 1 still waits for the
expected review signals to settle before fixing. This is the playbook's Phase 4
rule — defer to it for exact bodies.

**Merge needs admin.** `main` is ruleset-guarded — `gh pr merge --squash` will
show BLOCKED. Retry with `gh pr merge --admin --squash`; the ruleset's
Expand Down Expand Up @@ -147,8 +149,8 @@ self-resume signal. Either:
## The loop (summary — full detail in the playbook)

- **Phase 0 (first run):** safety rails (clean tree, GitHub origin, refuse
`main`) → commit → push → open PR (`ade`, gh fallback) → `@copilot` ping
write state, schedule first wake.
`main`) → commit → push → open PR (`ade`, gh fallback) → write state
schedule first wake.
- **Phase 1 — Poll:** wait for BOTH CI terminal AND review bots terminal. Return
a structured summary (merged / conflicting / ciFailed / newComments). Don't fix
on a partial signal.
Expand Down
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ ade search --status --text # index doc counts,
ade prs create --lane lane-id --base main --title "Fix checkout flow" --text # prints GitHub + ADE PR URLs
ade prs create --lane lane-id --base main --close-linear-issue-on-merge
ade prs list-open --text
ade prs github-snapshot --include-external-closed
ade prs github-snapshot --include-external-closed --history-page-limit 4
ade prs github-snapshot --include-state-counts --no-revalidate
ade prs checks pr-id --text
ade prs comments pr-id --text
ade run defs --text
Expand Down
5 changes: 4 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,8 +1301,12 @@ export async function createAdeRuntime(args: {
// by pushPublisherService.start() (declared below), so it stays empty and inert
// when push publishing is not running.
const pushPrNotificationSubscribers = new Set<(notification: PushPrNotification) => void>();
let syncService: ReturnType<typeof createSyncService> | null = null;
const emitPrEvent = (event: PrEventPayload): void => {
pushEvent("runtime", { type: "pr_event", event });
if (event.type === "prs-updated") {
syncService?.notifyPrsUpdated();
}
if (event.type === "pr-notification" && pushPrNotificationSubscribers.size > 0) {
const notification: PushPrNotification = {
kind: event.kind,
Expand Down Expand Up @@ -1571,7 +1575,6 @@ export async function createAdeRuntime(args: {
}

let externalSessionsService: ReturnType<typeof createExternalSessionsService> | null = null;
let syncService: ReturnType<typeof createSyncService> | null = null;
if (resolvedArgs.syncRuntime?.enabled && agentChatService) {
const { createSyncService } = await import("./services/sync/syncService");
syncService = createSyncService({
Expand Down
17 changes: 16 additions & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4944,14 +4944,19 @@ describe("ADE CLI", () => {
});
});

it("forwards PR GitHub snapshot full-history flag to the runtime action", () => {
it("forwards bounded PR GitHub snapshot options to the runtime action", () => {
const snapshot = buildCliPlan([
"prs",
"github-snapshot",
"--include-external-closed",
"--history-page-limit",
"4",
"--include-state-counts",
"--no-revalidate",
]);
expect(snapshot.kind).toBe("execute");
if (snapshot.kind !== "execute") return;
expect(snapshot.label).toBe("PR GitHub snapshot");
expect(snapshot.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
Expand All @@ -4960,9 +4965,19 @@ describe("ADE CLI", () => {
args: {
force: false,
includeExternalClosed: true,
historyPageLimit: 4,
includeStateCounts: true,
revalidate: false,
},
},
});

expect(() => buildCliPlan([
"prs",
"github-snapshot",
"--history-page-limit",
"0",
])).toThrow("--history-page-limit must be a positive integer.");
});

it("maps discoverable git status, sync, and conflict helpers to existing actions", () => {
Expand Down
19 changes: 17 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1525,8 +1525,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade prs link --lane <lane> --url <pr-url> Map an existing GitHub PR to a lane
$ ade prs checks <pr> --text Show check status
$ ade prs comments <pr> --text Show unresolved review work
$ ade prs github-snapshot --include-external-closed
Include closed external PR history in the GitHub snapshot
$ ade prs github-snapshot --include-external-closed --history-page-limit 4
Include bounded closed PR history in the GitHub snapshot
$ ade prs github-snapshot --include-state-counts --no-revalidate
Include exact state totals without a background refresh
$ ade prs resolve-thread <pr> --thread <id> Resolve a review thread
$ ade prs labels set <pr> ready-to-merge Replace labels
$ ade prs reviewers request <pr> alice bob Request reviewers
Expand Down Expand Up @@ -5776,6 +5778,19 @@ function buildPrPlan(args: string[]): CliPlan {
if (readFlag(args, ["--include-external-closed", "--include-closed-external"])) {
snapshotArgs.includeExternalClosed = true;
}
const historyPageLimit = readIntOption(args, ["--history-page-limit", "--history-pages"]);
if (historyPageLimit !== undefined) {
if (historyPageLimit <= 0) {
throw new CliUsageError("--history-page-limit must be a positive integer.");
}
snapshotArgs.historyPageLimit = historyPageLimit;
}
if (readFlag(args, ["--include-state-counts", "--state-counts"])) {
snapshotArgs.includeStateCounts = true;
}
if (readFlag(args, ["--no-revalidate"])) {
snapshotArgs.revalidate = false;
}
return {
kind: "execute",
label: "PR GitHub snapshot",
Expand Down
47 changes: 47 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2946,6 +2946,53 @@ async function connectPeer(
return { ws, ...tracked };
}

describe("PR snapshot invalidation push", () => {
beforeEach(() => {
publishMock.mockReset();
spawnMock.mockReset();
bonjourDestroyMock.mockReset();
bonjourConstructorMock.mockReset();
spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() }));
});

it("broadcasts one lightweight prs_updated envelope to an authenticated peer", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const base = createHostArgs(projectRoot, []);
const host = createSyncHostService({
...base,
deviceRegistryService: {
...base.deviceRegistryService,
upsertPeerMetadata: vi.fn(),
},
} as unknown as Parameters<typeof createSyncHostService>[0]);
let peer: Awaited<ReturnType<typeof connectPeer>> | null = null;
try {
const port = await host.waitUntilListening();
peer = await connectPeer(port, host.getBootstrapToken(), "ios-pr-invalidation");
const envelopeCount = peer.envelopes.length;

host.broadcastPrsUpdated();

const envelope = await waitForValue(
() => peer?.envelopes.slice(envelopeCount).find((entry) => entry.type === "prs_updated"),
"prs_updated push",
);
const payload = envelope.payload as { updatedAt?: string };
expect(envelope.type).toBe("prs_updated");
expect(payload).toEqual({ updatedAt: expect.any(String) });
expect(Number.isNaN(Date.parse(payload.updatedAt ?? ""))).toBe(false);
} finally {
try {
peer?.ws.close();
} catch {
// ignore
}
await host.dispose();
cleanup();
}
});
});

describe("outbound changeset ack retries", () => {
beforeEach(() => {
publishMock.mockReset();
Expand Down
14 changes: 14 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5962,6 +5962,20 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
broadcastBrainStatus();
},

/**
* Tell connected controllers that the host's cached GitHub projection has
* changed. The payload is intentionally tiny; clients fetch the newest
* projected snapshot once, while the normal CRR stream continues to carry
* mapped PR rows. This covers unmapped webhook updates without polling.
*/
broadcastPrsUpdated(): void {
const payload = { updatedAt: nowIso() };
for (const peer of peers) {
if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue;
send(peer.ws, "prs_updated", payload);
}
},

async broadcastProjectCatalog(): Promise<void> {
const payload = await buildProjectCatalogPayload();
if (bonjourPort != null) {
Expand Down
37 changes: 37 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,43 @@ function makePairingConnectInfo(
}

describe("createSyncRemoteCommandService", () => {
it("forwards bounded GitHub history pagination for mobile PR lists", async () => {
const getGithubSnapshot = vi.fn().mockResolvedValue({ repoPullRequests: [] });
const { service } = createService({ prService: { getGithubSnapshot } });

await service.execute(makePayload("prs.getGitHubSnapshot", {
includeExternalClosed: true,
historyPageLimit: 4.8,
revalidate: false,
includeStateCounts: true,
}));

expect(getGithubSnapshot).toHaveBeenCalledWith({
force: false,
includeExternalClosed: true,
historyPageLimit: 4,
revalidate: false,
includeStateCounts: true,
});
});

it("serves unmapped PR detail through one coordinate-based mobile command", async () => {
const detail = { snapshot: { detail: null, checks: [] }, reviewThreads: [], actionRuns: [], activity: [] };
const getMobileGithubDetail = vi.fn().mockResolvedValue(detail);
const { service } = createService({ prService: { getMobileGithubDetail } });

await expect(service.execute(makePayload("prs.getMobileGithubDetail", {
repoOwner: "arul28",
repoName: "ADE",
githubPrNumber: 849,
}))).resolves.toEqual(detail);
expect(getMobileGithubDetail).toHaveBeenCalledWith({
repoOwner: "arul28",
repoName: "ADE",
githubPrNumber: 849,
});
});

it("advertises the complete paired-client analytics command contract", async () => {
const flush = vi.fn(async () => true);
const { service } = createService({ productAnalyticsService: { flush } });
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4646,6 +4646,11 @@ function registerPrAndDeeplinkRemoteCommands({ args, register }: RemoteCommandRe
args.prService.getGithubSnapshot({
force: payload.force === true,
includeExternalClosed: payload.includeExternalClosed === true,
revalidate: payload.revalidate !== false,
includeStateCounts: payload.includeStateCounts === true,
...(typeof payload.historyPageLimit === "number" && Number.isFinite(payload.historyPageLimit)
? { historyPageLimit: Math.max(1, Math.floor(payload.historyPageLimit)) }
: {}),
}));
register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads")));
register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns")));
Expand All @@ -4665,6 +4670,8 @@ function registerPrAndDeeplinkRemoteCommands({ args, register }: RemoteCommandRe
register("prs.getReviewsByGithub", { viewerAllowed: true }, async (payload) => args.prService.getReviewsByGithub(requirePrGithubCoords(payload, "prs.getReviewsByGithub")));
register("prs.getCommentsByGithub", { viewerAllowed: true }, async (payload) => args.prService.getCommentsByGithub(requirePrGithubCoords(payload, "prs.getCommentsByGithub")));
register("prs.getReviewThreadsByGithub", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreadsByGithub(requirePrGithubCoords(payload, "prs.getReviewThreadsByGithub")));
register("prs.getMobileGithubDetail", { viewerAllowed: true }, async (payload) =>
args.prService.getMobileGithubDetail(requirePrGithubCoords(payload, "prs.getMobileGithubDetail")));
register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload)));
register("prs.createQueue", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createQueuePrs(parseCreateQueuePrsArgs(payload)));
register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload)));
Expand Down
4 changes: 4 additions & 0 deletions apps/ade-cli/src/services/sync/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,10 @@ export function createSyncService(args: SyncServiceArgs) {
hostService?.handlePtyExit(event);
},

notifyPrsUpdated(): void {
hostService?.broadcastPrsUpdated();
},

getHostService(): SyncHostService | null {
return hostService;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,10 @@ describe("ADE_ACTION_ALLOWLIST shape", () => {
expect(actions).toContain("listDeleteProgress");
});

it("exposes pr.listPrsByLane for runtime-backed drawer PR pills", () => {
it("exposes runtime-backed PR reads", () => {
const actions = ADE_ACTION_ALLOWLIST.pr ?? [];
expect(actions).toContain("listPrsByLane");
expect(actions).toContain("getMobileGithubDetail");
});

it("exposes ade_project.clearLocalData for runtime-backed cleanup", () => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"getIntegrationResolutionState",
"getMergeContext",
"getMergeContexts",
"getMobileGithubDetail",
"getMobileSnapshot",
"getPrHealth",
"getQueueState",
Expand Down
Loading
Loading