Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/mobile/src/state/use-thread-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function useThreadPr(
projectId: thread.linkedPullRequest.projectId,
repository: thread.linkedPullRequest.repository,
number: thread.linkedPullRequest.number,
url: thread.linkedPullRequest.url,
},
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
expect(second.capabilities.threadPullRequestUrlLinking).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadTitleRegeneration: true,
threadPullRequestLinking: true,
threadPullRequestUrlLinking: true,
environmentIcon: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" || desktopAppUpdate
Expand Down
22 changes: 19 additions & 3 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
readonly projectId: ProjectId;
readonly repository: string;
readonly number: number;
readonly url?: string | undefined;
}>
>([]);
const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
Expand Down Expand Up @@ -338,7 +339,12 @@ describe("ThreadSettlementReactor", () => {
{ cwd: "/workspace/project", branch: "inactive-feature" },
]);
assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
{ projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
{
projectId: LINKED_PROJECT_ID,
repository: "owner/repository",
number: 42,
url: "https://example.test/owner/repository/pull/42",
},
]);
assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
}).pipe(Effect.provide(fixture.layer));
Expand Down Expand Up @@ -581,7 +587,12 @@ describe("ThreadSettlementReactor", () => {

assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
{ projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
{
projectId: LINKED_PROJECT_ID,
repository: "owner/repository",
number: 10,
url: "https://example.test/owner/repository/pull/10",
},
]);
assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
}).pipe(Effect.provide(fixture.layer));
Expand Down Expand Up @@ -631,7 +642,12 @@ describe("ThreadSettlementReactor", () => {
{ cwd: "/workspace/project-root", branch: "saved-feature" },
]);
assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
{ projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
{
projectId: LINKED_PROJECT_ID,
repository: "owner/repository",
number: 77,
url: "https://example.test/owner/repository/pull/77",
},
]);
assert.deepStrictEqual(
new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const make = Effect.gen(function* () {
thread.linkedPullRequest.projectId,
thread.linkedPullRequest.repository,
thread.linkedPullRequest.number,
thread.linkedPullRequest.url,
]);
}
if (thread.branch === null) return JSON.stringify(["none", thread.id]);
Expand Down Expand Up @@ -91,6 +92,7 @@ export const make = Effect.gen(function* () {
projectId: thread.linkedPullRequest.projectId,
repository: thread.linkedPullRequest.repository,
number: thread.linkedPullRequest.number,
url: thread.linkedPullRequest.url,
},
{ recoverTransientFailure: false },
);
Expand Down
190 changes: 190 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3172,6 +3172,159 @@ it.effect("shares linked summaries and reuses them for display without asking th
}),
);

it.effect("reads a linked pull request URL from a non-Git project", () =>
Effect.gen(function* () {
const requests: Array<{
readonly cwd: string;
readonly host: string;
readonly repository: string;
readonly number: number;
}> = [];
const service = yield* makeService({
projects: [project({ id: "parent", title: "work", workspaceRoot: "/work" })],
providers: [
fakeProvider("github", {
getChangeRequestSummary: (input) => {
requests.push(input);
return Effect.succeed(changeRequest(9435, "2026-09-03T00:00:00Z"));
},
}),
],
});

const summary = yield* service.summary({
projectId: "parent" as ProjectId,
repository: "pingdotgg/t3code",
number: 9435,
url: "https://github.com/pingdotgg/t3code/pull/9435",
});

assert.deepStrictEqual(requests, [
{
cwd: "/work",
host: "github.com",
repository: "pingdotgg/t3code",
number: 9435,
},
]);
assert.strictEqual(summary.projectId, "parent");
assert.strictEqual(summary.number, 9435);
}),
);

it.effect("keeps URL-only summaries on different hosts separate", () =>
Effect.gen(function* () {
let githubCalls = 0;
let bitbucketCalls = 0;
const service = yield* makeService({
projects: [project({ id: "parent", title: "work", workspaceRoot: "/work" })],
providers: [
fakeProvider("github", {
getChangeRequestSummary: () => {
githubCalls += 1;
return Effect.succeed(changeRequest(7, "2026-09-03T00:00:00Z"));
},
}),
fakeProvider("bitbucket", {
getChangeRequestSummary: () => {
bitbucketCalls += 1;
return Effect.succeed(changeRequest(7, "2026-09-03T00:00:00Z"));
},
}),
],
});
const reference = {
projectId: "parent" as ProjectId,
repository: "acme/web",
number: 7,
};

yield* service.summary({ ...reference, url: "https://github.com/acme/web/pull/7" });
yield* service.summary({
...reference,
url: "https://bitbucket.org/acme/web/pull-requests/7",
});

assert.deepStrictEqual([githubCalls, bitbucketCalls], [1, 1]);
}),
);

it.effect("does not use providers that cannot resolve URL-only summary hosts", () =>
Effect.gen(function* () {
for (const testCase of [
{
provider: "gitlab" as const,
repository: "pingdotgg/t3code",
url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/9435",
},
{
provider: "azure-devops" as const,
repository: "pingdotgg/t3code/_git/t3code",
url: "https://dev.azure.com/pingdotgg/t3code/_git/t3code/pullrequest/9435",
},
{
provider: "bitbucket" as const,
repository: "pingdotgg/t3code",
url: "https://bitbucket.acme.test/pingdotgg/t3code/pull-requests/9435",
},
{
provider: "github" as const,
repository: "pingdotgg/t3code",
url: "https://github.acme.test/pingdotgg/t3code/pull/9435",
},
]) {
const service = yield* makeService({
projects: [project({ id: "parent", title: "work", workspaceRoot: "/work" })],
providers: [
fakeProvider(testCase.provider, {
getChangeRequest: () => Effect.die("unsupported URL-only provider was called"),
}),
],
});

const error = yield* Effect.flip(
service.summary({
projectId: "parent" as ProjectId,
repository: testCase.repository,
number: 9435,
url: testCase.url,
}),
);

assert.strictEqual(error._tag, "PullRequestUnavailableError");
}
}),
);

it.effect("refuses a URL-only summary whose repository does not match the URL", () =>
Effect.gen(function* () {
let calls = 0;
const service = yield* makeService({
projects: [project({ id: "parent", title: "work", workspaceRoot: "/work" })],
providers: [
fakeProvider("github", {
getChangeRequestSummary: () => {
calls += 1;
return Effect.succeed(changeRequest(9435, "2026-09-03T00:00:00Z"));
},
}),
],
});

const error = yield* Effect.flip(
service.summary({
projectId: "parent" as ProjectId,
repository: "other/repository",
number: 9435,
url: "https://github.com/pingdotgg/t3code/pull/9435",
}),
);

assert.strictEqual(error._tag, "PullRequestOperationError");
assert.strictEqual(calls, 0);
}),
);

it.effect("answers a known pull request immediately while the host refreshes", () =>
Effect.gen(function* () {
const gate = yield* Deferred.make<void>();
Expand Down Expand Up @@ -3256,6 +3409,43 @@ it.effect("reuses an observed merged state for strict settlement reads", () =>
}),
);

it.effect("shares detail updates with a registered project's linked summary", () =>
Effect.gen(function* () {
let summaryCalls = 0;
const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
const linkedReference = {
...reference,
url: "https://github.com/acme/web/pull/1",
};
const service = yield* makeService({
projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
providers: [
fakeProvider("github", {
getChangeRequest: () =>
Effect.succeed({
...hostedChangeRequest("merged body", 4),
url: linkedReference.url,
state: "merged",
updatedAt: "2026-07-03T00:00:00Z",
}),
getChangeRequestSummary: () => {
summaryCalls += 1;
return Effect.succeed({
...changeRequest(1, "2026-07-02T00:00:00Z"),
url: linkedReference.url,
});
},
}),
],
});

assert.strictEqual((yield* service.summary(linkedReference)).state, "open");
yield* service.detail(reference);
assert.strictEqual((yield* service.summary(linkedReference)).state, "merged");
assert.strictEqual(summaryCalls, 1);
}),
);

it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () =>
Effect.gen(function* () {
const gate = yield* Deferred.make<void>();
Expand Down
Loading
Loading