| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Option from "effect/Option"; |
| 4 | import type { |
| 5 | RepositoryIdentity, |
| 6 | ChangeRequest, |
| 7 | ChangeRequestState, |
| 8 | SourceControlProviderError, |
| 9 | SourceControlProviderInfo, |
| 10 | SourceControlProviderKind, |
| 11 | SourceControlRepositoryCloneUrls, |
| 12 | SourceControlRepositoryVisibility, |
| 13 | } from "@t3tools/contracts"; |
| 14 | |
| 15 | export interface SourceControlLinkSubject { |
| 16 | readonly title: string; |
| 17 | readonly body: string | null; |
| 18 | } |
| 19 | |
| 20 | /** Return undefined synchronously for unsupported URLs, without starting a lookup. */ |
| 21 | export type ResolveSourceControlLink = (input: { |
| 22 | readonly cwd: string; |
| 23 | readonly url: URL; |
| 24 | }) => Effect.Effect<SourceControlLinkSubject, SourceControlProviderError> | undefined; |
| 25 | |
| 26 | export 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 | |
| 34 | export interface SourceControlRefSelector { |
| 35 | readonly refName: string; |
| 36 | readonly owner?: string; |
| 37 | readonly repository?: string; |
| 38 | } |
| 39 | |
| 40 | const 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 | */ |
| 47 | export 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 | |
| 70 | export 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 | |
| 79 | function normalizeSourceBranch(headSelector: string): string { |
| 80 | return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); |
| 81 | } |
| 82 | |
| 83 | export function sourceBranch(input: { |
| 84 | readonly headSelector: string; |
| 85 | readonly source?: SourceControlRefSelector; |
| 86 | }): string { |
| 87 | return input.source?.refName ?? normalizeSourceBranch(input.headSelector); |
| 88 | } |
| 89 | |
| 90 | export 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). */ |
| 98 | export 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 | |
| 112 | export 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") {} |