1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type * as Option from "effect/Option";
4import type {
5 RepositoryIdentity,
6 ChangeRequest,
7 ChangeRequestState,
8 SourceControlProviderError,
9 SourceControlProviderInfo,
10 SourceControlProviderKind,
11 SourceControlRepositoryCloneUrls,
12 SourceControlRepositoryVisibility,
13} from "@t3tools/contracts";
14
15export interface SourceControlLinkSubject {
16 readonly title: string;
17 readonly body: string | null;
18}
19
20/** Return undefined synchronously for unsupported URLs, without starting a lookup. */
21export type ResolveSourceControlLink = (input: {
22 readonly cwd: string;
23 readonly url: URL;
24}) => Effect.Effect<SourceControlLinkSubject, SourceControlProviderError> | undefined;
25
26export interface SourceControlProviderContext {
27 readonly provider: SourceControlProviderInfo;
28 readonly remoteName: string;
29 readonly remoteUrl: string;
30 /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */
31 readonly requestedHost?: string;
32}
33
34export interface SourceControlRefSelector {
35 readonly refName: string;
36 readonly owner?: string;
37 readonly repository?: string;
38}
39
40const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256;
41
42/**
43 * Sanitizes user-provided source-control identifiers before attaching them to
44 * contract errors. This is intentionally narrower than request validation: it
45 * only strips URL secrets and bounds diagnostic values sent over transport.
46 */
47export function transportSafeSourceControlErrorValue(value: string): string {
48 let printable = "";
49 for (const character of value) {
50 const codePoint = character.codePointAt(0);
51 printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character;
52 }
53 const normalized = printable.trim().replace(/\s+/gu, " ");
54
55 let safe = normalized;
56 try {
57 const url = new URL(normalized);
58 url.username = "";
59 url.password = "";
60 url.search = "";
61 url.hash = "";
62 safe = url.toString();
63 } catch {
64 // Plain repository and change-request identifiers are not URLs.
65 }
66
67 return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH);
68}
69
70export function parseSourceControlOwnerRef(
71 headSelector: string,
72): SourceControlRefSelector | undefined {
73 const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim());
74 const owner = match?.[1]?.trim();
75 const refName = match?.[2]?.trim();
76 return owner && refName ? { owner, refName } : undefined;
77}
78
79function normalizeSourceBranch(headSelector: string): string {
80 return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
81}
82
83export function sourceBranch(input: {
84 readonly headSelector: string;
85 readonly source?: SourceControlRefSelector;
86}): string {
87 return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
88}
89
90export function sourceControlRefFromInput(input: {
91 readonly headSelector: string;
92 readonly source?: SourceControlRefSelector;
93}): SourceControlRefSelector | undefined {
94 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
95}
96
97/** The repository path a remote URL names (`owner/name`, or deeper for nested groups). */
98export function repositoryPathFromRemoteUrl(url: string | null): string | null {
99 const trimmed = url?.trim() ?? "";
100 if (trimmed.length === 0) {
101 return null;
102 }
103
104 const match =
105 /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
106 trimmed,
107 );
108 const path = match?.[1]?.trim() ?? "";
109 return path.length > 0 ? path : null;
110}
111
112export class SourceControlProvider extends Context.Service<
113 SourceControlProvider,
114 {
115 readonly kind: SourceControlProviderKind;
116 /**
117 * How to look up a branch's change requests: which head selectors to ask about, and how many
118 * results to read per selector. The caller checks the owner against what comes back, so a
119 * host that cannot search `owner:branch` can drop those selectors without losing anything.
120 * Absent asks about every selector, reading 1 for the open lookup and 20 for any state.
121 */
122 readonly headBranchProbe?: (input: {
123 readonly headSelectors: ReadonlyArray<string>;
124 readonly state: "open" | "all";
125 }) => { readonly headSelectors: ReadonlyArray<string>; readonly limit: number };
126 /**
127 * The repository's change request template at `treeish`, for change request content
128 * generation to follow. Absent means the host has no template convention.
129 */
130 readonly readChangeRequestTemplate?: (input: {
131 readonly cwd: string;
132 readonly treeish: string;
133 }) => Effect.Effect<Option.Option<string>>;
134 /**
135 * The repository's `owner/name` from a remote URL, for a host whose remote paths can carry
136 * more than that (Forgejo's HTTP installation mount). Absent means the whole path.
137 */
138 readonly repositoryNameFromRemoteUrl?: (url: string) => string | null;
139 /**
140 * Fills in what a repository identity read from git cannot know, such as the browser URL of
141 * a host the remote URL does not name. `resolveContext` asks the registry which host (and
142 * base URL) serves a remote. Only consulted for identities this host may own.
143 */
144 readonly refineRepositoryIdentity?: (input: {
145 readonly identity: RepositoryIdentity;
146 readonly resolveContext: (input: {
147 readonly cwd: string;
148 readonly context: SourceControlProviderContext;
149 }) => Effect.Effect<SourceControlProviderContext | null, SourceControlProviderError>;
150 }) => Effect.Effect<RepositoryIdentity, SourceControlProviderError>;
151 /** Optional capability for issue and change-request subjects. */
152 readonly resolveLink?: ResolveSourceControlLink;
153 readonly listChangeRequests: (input: {
154 readonly cwd: string;
155 readonly context?: SourceControlProviderContext;
156 readonly source?: SourceControlRefSelector;
157 readonly headSelector: string;
158 readonly state: ChangeRequestState | "all";
159 readonly limit?: number;
160 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
161 readonly getChangeRequest: (input: {
162 readonly cwd: string;
163 readonly context?: SourceControlProviderContext;
164 readonly reference: string;
165 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
166 readonly createChangeRequest: (input: {
167 readonly cwd: string;
168 readonly context?: SourceControlProviderContext;
169 readonly source?: SourceControlRefSelector;
170 readonly target?: SourceControlRefSelector;
171 readonly baseRefName: string;
172 readonly headSelector: string;
173 readonly title: string;
174 readonly bodyFile: string;
175 }) => Effect.Effect<void, SourceControlProviderError>;
176 readonly getRepositoryCloneUrls: (input: {
177 readonly cwd: string;
178 readonly context?: SourceControlProviderContext;
179 readonly repository: string;
180 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
181 readonly createRepository: (input: {
182 readonly cwd: string;
183 readonly repository: string;
184 readonly visibility: SourceControlRepositoryVisibility;
185 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
186 readonly getDefaultBranch: (input: {
187 readonly cwd: string;
188 readonly context?: SourceControlProviderContext;
189 }) => Effect.Effect<string | null, SourceControlProviderError>;
190 readonly checkoutChangeRequest: (input: {
191 readonly cwd: string;
192 readonly context?: SourceControlProviderContext;
193 readonly reference: string;
194 readonly force?: boolean;
195 }) => Effect.Effect<void, SourceControlProviderError>;
196 }
197>()("@t3tools/source-control-core/server/SourceControlProvider") {}