1import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts";
2
3/** The little of a project this needs: who holds it, and which repository it is a copy of. */
4export interface AssignableProject {
5 readonly id: ProjectId;
6 readonly environmentId: EnvironmentId;
7 readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined;
8}
9
10/**
11 * The remote's normalized URL is what says "same repository" across machines — it comes from the
12 * remote, not from a local path. Empty where the project has no identity to compare with.
13 *
14 * `normalizeGitRemoteUrl` already lower-cases the whole remote, so the key arrives cased one way
15 * whatever the remote said; the fold here only guards a key assembled some other way.
16 */
17function repositoryKey(project: AssignableProject): string | undefined {
18 return project.repositoryIdentity?.canonicalKey?.toLowerCase();
19}
20
21/**
22 * Two servers can hold the same repository, and both would list the same pull requests, so one
23 * copy is picked by its repository key.
24 *
25 * A project with no identity is never de-duplicated: nothing proves it is a copy of anything, and
26 * dropping it would lose its rows outright.
27 */
28export function assignProjectsToEnvironments(
29 projects: ReadonlyArray<AssignableProject>,
30 environmentIds: ReadonlyArray<EnvironmentId>,
31 preferredEnvironmentId?: EnvironmentId | null,
32): Map<EnvironmentId, ProjectId[]> {
33 const rank = new Map(environmentIds.map((id, index) => [id, index] as const));
34 // Which server lists each repository: the preferred one where it has it, else the first.
35 const owner = new Map<string, EnvironmentId>();
36 for (const project of projects) {
37 const key = repositoryKey(project);
38 if (!key) continue;
39 const environmentRank = rank.get(project.environmentId);
40 if (environmentRank === undefined) continue;
41 const current = owner.get(key);
42 if (current === undefined) {
43 owner.set(key, project.environmentId);
44 continue;
45 }
46 if (current === preferredEnvironmentId) continue;
47 if (
48 project.environmentId === preferredEnvironmentId ||
49 environmentRank < (rank.get(current) ?? Number.MAX_SAFE_INTEGER)
50 ) {
51 owner.set(key, project.environmentId);
52 }
53 }
54 const assignment = new Map<EnvironmentId, ProjectId[]>();
55 for (const project of projects) {
56 if (!rank.has(project.environmentId)) continue;
57 const key = repositoryKey(project);
58 if (key && owner.get(key) !== project.environmentId) continue;
59 const listed = assignment.get(project.environmentId);
60 if (listed === undefined) assignment.set(project.environmentId, [project.id]);
61 else listed.push(project.id);
62 }
63 return assignment;
64}
65
66/** A copy of the repository the reader could act on, named by the server holding it. */
67export interface PickableEnvironment {
68 readonly environmentId: EnvironmentId;
69 readonly projectId: ProjectId;
70 readonly workspaceRoot: string;
71 readonly label: string;
72 readonly machine?: EnvironmentMachineKind;
73}
74
75/**
76 * Which servers a pull request could be acted on, given the one it is listed under.
77 *
78 * Listing picks a single server per repository so each pull request appears once; acting is a
79 * different question. Checking the branch out, or handing it to a thread, happens wherever the
80 * reader means to work — and the copy the listing happened not to pick is just as real.
81 *
82 * Empty where there is no choice to offer: one server, no repository identity to match copies by,
83 * or projects not read yet. The caller renders nothing then, so a lone server keeps the surface it
84 * has always had.
85 */
86export function resolvePickableEnvironments(
87 current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
88 projects: ReadonlyArray<AssignableProject & { readonly workspaceRoot: string }>,
89 environments: ReadonlyArray<{
90 readonly environmentId: EnvironmentId;
91 readonly label: string;
92 readonly machine?: EnvironmentMachineKind;
93 }>,
94): ReadonlyArray<PickableEnvironment> {
95 const own = projects.find(
96 (project) =>
97 project.environmentId === current.environmentId && project.id === current.projectId,
98 );
99 const key = own === undefined ? undefined : repositoryKey(own);
100 const ownEnvironment = environments.find(
101 (environment) => environment.environmentId === current.environmentId,
102 );
103 if (own === undefined || !key || ownEnvironment === undefined) return [];
104 const others = environments.flatMap((environment) => {
105 if (environment.environmentId === current.environmentId) return [];
106 // One entry per server, whichever copy comes first: a server holding two worktrees of the
107 // repository is still one place to act, and what is being picked here is the server.
108 const copy = projects.find(
109 (project) =>
110 project.environmentId === environment.environmentId && repositoryKey(project) === key,
111 );
112 return copy === undefined
113 ? []
114 : [
115 {
116 environmentId: environment.environmentId,
117 projectId: copy.id,
118 workspaceRoot: copy.workspaceRoot,
119 label: environment.label,
120 ...(environment.machine === undefined ? {} : { machine: environment.machine }),
121 },
122 ];
123 });
124 if (others.length === 0) return [];
125 // The panel's own server first: it is what everything else on the panel is showing, so it is
126 // also what acting means until the reader says otherwise.
127 return [
128 {
129 environmentId: current.environmentId,
130 projectId: own.id,
131 workspaceRoot: own.workspaceRoot,
132 label: ownEnvironment.label,
133 ...(ownEnvironment.machine === undefined ? {} : { machine: ownEnvironment.machine }),
134 },
135 ...others,
136 ];
137}