Skip to content

Commit 3efe52c

Browse files
fix(cli): address github connection review feedback
1 parent 7e7d639 commit 3efe52c

4 files changed

Lines changed: 207 additions & 8 deletions

File tree

docs/product/command-spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ Behavior:
251251
- stores the repository connection server-side through the Management API
252252
- does not write repository data to `prisma.config.ts`
253253
- does not create branches synchronously
254-
- enables platform webhook automation to map GitHub branch activity to Prisma Branch state
254+
- when the connection is active, enables platform webhook automation to map GitHub branch activity to Prisma Branch state
255255

256256
Current backend contract:
257257

packages/cli/src/controllers/project.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ async function listScmInstallations(
653653
): Promise<ScmInstallationResponse[]> {
654654
const installations: ScmInstallationResponse[] = [];
655655
let cursor: string | undefined;
656+
const seenCursors = new Set<string>();
656657

657658
do {
658659
const { data, error, response } = await api.GET("/v1/scm-installations", {
@@ -670,7 +671,12 @@ async function listScmInstallations(
670671
}
671672

672673
installations.push(...data.data);
673-
cursor = data.pagination.hasMore && data.pagination.nextCursor ? data.pagination.nextCursor : undefined;
674+
cursor = readNextPaginationCursor(
675+
data.pagination,
676+
seenCursors,
677+
"Failed to inspect GitHub App installations",
678+
response,
679+
);
674680
} while (cursor);
675681

676682
return installations;
@@ -683,6 +689,7 @@ async function findRepositoryInInstallation(
683689
): Promise<ScmRepositoryResponse | null> {
684690
const expectedFullName = repository.fullName.toLowerCase();
685691
let cursor: string | undefined;
692+
const seenCursors = new Set<string>();
686693

687694
do {
688695
const { data, error, response } = await api.GET("/v1/scm-installations/{installationId}/repositories", {
@@ -706,12 +713,40 @@ async function findRepositoryInInstallation(
706713
return matchedRepository;
707714
}
708715

709-
cursor = data.pagination.hasMore && data.pagination.nextCursor ? data.pagination.nextCursor : undefined;
716+
cursor = readNextPaginationCursor(
717+
data.pagination,
718+
seenCursors,
719+
"Failed to inspect GitHub repositories",
720+
response,
721+
);
710722
} while (cursor);
711723

712724
return null;
713725
}
714726

727+
function readNextPaginationCursor(
728+
pagination: { hasMore: boolean; nextCursor: string | null },
729+
seenCursors: Set<string>,
730+
summary: string,
731+
response: Response | undefined,
732+
): string | undefined {
733+
const nextCursor = pagination.hasMore && pagination.nextCursor ? pagination.nextCursor : undefined;
734+
if (!nextCursor) {
735+
return undefined;
736+
}
737+
738+
if (seenCursors.has(nextCursor)) {
739+
throw repoConnectionApiError(summary, response, {
740+
error: {
741+
message: "Pagination cursor did not advance.",
742+
},
743+
});
744+
}
745+
746+
seenCursors.add(nextCursor);
747+
return nextCursor;
748+
}
749+
715750
async function findRepositoryInInstallationIfAvailable(
716751
api: SourceRepositoryApiClient,
717752
installationId: string,

packages/cli/src/presenters/project.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CommandDescriptor } from "../shell/command-meta";
22
import type { CommandContext } from "../shell/runtime";
33
import type {
4+
GitRepositoryConnection,
45
ProjectListResult,
56
ProjectRepositoryConnectionResult,
67
ProjectShowResult,
@@ -87,11 +88,7 @@ export function renderGitConnect(
8788
],
8889
operationDescription: "Applying repository connection",
8990
operationCount: 1,
90-
details: [
91-
connection.status === "active"
92-
? "GitHub branch automation is active for this project."
93-
: "GitHub branch automation is pending GitHub App installation.",
94-
],
91+
details: [formatGitConnectionDetail(connection.status)],
9592
},
9693
context.ui,
9794
);
@@ -135,3 +132,16 @@ function formatProjectSource(source: ProjectShowResult["resolution"]["projectSou
135132
return "prompt";
136133
}
137134
}
135+
136+
function formatGitConnectionDetail(status: GitRepositoryConnection["status"]): string {
137+
switch (status) {
138+
case "active":
139+
return "GitHub branch automation is active for this project.";
140+
case "pending":
141+
return "GitHub branch automation is pending GitHub App installation.";
142+
case "archived":
143+
return "GitHub branch automation has been archived for this project.";
144+
default:
145+
return "GitHub repository is connected, but branch automation is not active.";
146+
}
147+
}

packages/cli/tests/project-real-mode.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,34 @@ function sourceRepositoryList(records: unknown[] = []) {
7979
};
8080
}
8181

82+
function scmInstallationRecord(overrides: Record<string, unknown> = {}) {
83+
return {
84+
id: "scminstall_123",
85+
type: "scm-installation",
86+
url: "https://api.prisma.test/v1/scm-installations/scminstall_123",
87+
provider: "github",
88+
installationId: 98765,
89+
accountId: 111,
90+
accountLogin: "prisma",
91+
accountType: "organization",
92+
suspended: false,
93+
createdAt: "2026-05-18T00:00:00.000Z",
94+
updatedAt: "2026-05-18T00:00:00.000Z",
95+
...overrides,
96+
};
97+
}
98+
99+
function scmRepositoryRecord(overrides: Record<string, unknown> = {}) {
100+
return {
101+
id: 999,
102+
type: "scm-repository",
103+
fullName: "prisma/other",
104+
defaultBranch: "main",
105+
isPrivate: false,
106+
...overrides,
107+
};
108+
}
109+
82110
describe("real project mode", () => {
83111
it("uses the real API path for project list and sorts by name then id", async () => {
84112
const readAuthState = mockAuthState();
@@ -811,6 +839,132 @@ describe("real project mode", () => {
811839
expect(post).not.toHaveBeenCalled();
812840
});
813841

842+
it("guards repeated GitHub App installation pagination cursors", async () => {
843+
const get = vi.fn().mockImplementation((pathName: string) => {
844+
if (pathName === "/v1/projects") {
845+
return mockClient().GET(pathName);
846+
}
847+
848+
if (pathName === "/v1/source-repositories") {
849+
return sourceRepositoryList();
850+
}
851+
852+
if (pathName === "/v1/scm-installations") {
853+
return {
854+
data: {
855+
data: [],
856+
pagination: {
857+
nextCursor: "repeat",
858+
hasMore: true,
859+
},
860+
},
861+
};
862+
}
863+
864+
throw new Error(`Unexpected path ${pathName}`);
865+
});
866+
const post = vi.fn();
867+
868+
vi.doMock("../src/lib/auth/auth-ops", () => ({
869+
readAuthState: mockAuthState(),
870+
performLogin: vi.fn(),
871+
performLogout: vi.fn(),
872+
}));
873+
vi.doMock("../src/lib/auth/guard", () => ({
874+
requireComputeAuth: vi.fn().mockResolvedValue(mockClient({ GET: get, POST: post })),
875+
}));
876+
877+
const { createTempCwd, createTestCommandContext } = await import("./helpers");
878+
const { runGitConnect } = await import("../src/controllers/project");
879+
const cwd = await createTempCwd();
880+
const stateDir = path.join(cwd, ".state");
881+
const { context } = await createTestCommandContext({
882+
cwd,
883+
stateDir,
884+
env: {
885+
...process.env,
886+
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
887+
},
888+
});
889+
890+
await expect(runGitConnect(context, "https://github.com/prisma/prisma-cli", { project: "proj_123" }))
891+
.rejects
892+
.toMatchObject({
893+
code: "REPO_CONNECTION_FAILED",
894+
why: "Pagination cursor did not advance.",
895+
});
896+
expect(post).not.toHaveBeenCalled();
897+
});
898+
899+
it("guards repeated GitHub repository pagination cursors", async () => {
900+
const get = vi.fn().mockImplementation((pathName: string) => {
901+
if (pathName === "/v1/projects") {
902+
return mockClient().GET(pathName);
903+
}
904+
905+
if (pathName === "/v1/source-repositories") {
906+
return sourceRepositoryList();
907+
}
908+
909+
if (pathName === "/v1/scm-installations") {
910+
return {
911+
data: {
912+
data: [scmInstallationRecord()],
913+
pagination: {
914+
nextCursor: null,
915+
hasMore: false,
916+
},
917+
},
918+
};
919+
}
920+
921+
if (pathName === "/v1/scm-installations/{installationId}/repositories") {
922+
return {
923+
data: {
924+
data: [scmRepositoryRecord()],
925+
pagination: {
926+
nextCursor: "repeat",
927+
hasMore: true,
928+
},
929+
},
930+
};
931+
}
932+
933+
throw new Error(`Unexpected path ${pathName}`);
934+
});
935+
const post = vi.fn();
936+
937+
vi.doMock("../src/lib/auth/auth-ops", () => ({
938+
readAuthState: mockAuthState(),
939+
performLogin: vi.fn(),
940+
performLogout: vi.fn(),
941+
}));
942+
vi.doMock("../src/lib/auth/guard", () => ({
943+
requireComputeAuth: vi.fn().mockResolvedValue(mockClient({ GET: get, POST: post })),
944+
}));
945+
946+
const { createTempCwd, createTestCommandContext } = await import("./helpers");
947+
const { runGitConnect } = await import("../src/controllers/project");
948+
const cwd = await createTempCwd();
949+
const stateDir = path.join(cwd, ".state");
950+
const { context } = await createTestCommandContext({
951+
cwd,
952+
stateDir,
953+
env: {
954+
...process.env,
955+
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
956+
},
957+
});
958+
959+
await expect(runGitConnect(context, "https://github.com/prisma/prisma-cli", { project: "proj_123" }))
960+
.rejects
961+
.toMatchObject({
962+
code: "REPO_CONNECTION_FAILED",
963+
why: "Pagination cursor did not advance.",
964+
});
965+
expect(post).not.toHaveBeenCalled();
966+
});
967+
814968
it("disconnects a GitHub repository through the source repositories API", async () => {
815969
const del = vi.fn().mockResolvedValue({});
816970
const get = vi.fn().mockImplementation((pathName: string) => {

0 commit comments

Comments
 (0)