| author | |
| committer | |
| log | f6e45028efb69d208a45aee8c2fa514adde44977 |
| tree | 9b7b76913381bbf155c96786e11de57c99ec2d91 |
| parent | 28f11ed7a5a2251ebe4689643b64425760ee5fe4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>38 files changed, 4064 insertions(+), 3735 deletions(-)
apps/server/package.json+1| ... | @@ -46,6 +46,7 @@ | ... | @@ -46,6 +46,7 @@ |
| 46 | "@t3tools/provider-opencode": "workspace:*", | 46 | "@t3tools/provider-opencode": "workspace:*", |
| 47 | "@t3tools/provider-pi": "workspace:*", | 47 | "@t3tools/provider-pi": "workspace:*", |
| 48 | "@t3tools/source-control-core": "workspace:*", | 48 | "@t3tools/source-control-core": "workspace:*", |
| 49 | "@t3tools/source-control-forgejo": "workspace:*", | ||
| 49 | "diff": "8.0.3", | 50 | "diff": "8.0.3", |
| 50 | "effect": "catalog:", | 51 | "effect": "catalog:", |
| 51 | "jose": "catalog:", | 52 | "jose": "catalog:", |
apps/server/scripts/evaluate-thread-titles.ts+2-4| ... | @@ -23,10 +23,9 @@ import { | ... | @@ -23,10 +23,9 @@ import { |
| 23 | } from "@t3tools/provider-core/server/threadTitleContext"; | 23 | } from "@t3tools/provider-core/server/threadTitleContext"; |
| 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; | 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; | ||
| 27 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; | 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 28 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; | 27 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; |
| 29 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; | 28 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; |
| 30 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; | 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; |
| 31 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; | 30 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; |
| 32 | import * as ServerSettings from "../src/serverSettings.ts"; | 31 | import * as ServerSettings from "../src/serverSettings.ts"; |
| ... | @@ -155,11 +154,10 @@ await Effect.runPromise( | ... | @@ -155,11 +154,10 @@ await Effect.runPromise( |
| 155 | Layer.mergeAll( | 154 | Layer.mergeAll( |
| 156 | GitHubApi.layerWithDependencies, | 155 | GitHubApi.layerWithDependencies, |
| 157 | GitLabCli.layer, | 156 | GitLabCli.layer, |
| 158 | ForgejoCli.layer, | 157 | SourceControlBuiltInDrivers.layer, |
| 159 | AzureDevOpsCli.layer, | 158 | AzureDevOpsCli.layer, |
| 160 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. | 159 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. |
| 161 | BitbucketApi.layer, | 160 | BitbucketApi.layer, |
| 162 | ServerSourceControlHost.layer, | ||
| 163 | ), | 161 | ), |
| 164 | ), | 162 | ), |
| 165 | // Default settings: no saved Bitbucket token, gh's own GitHub account choice. | 163 | // Default settings: no saved Bitbucket token, gh's own GitHub account choice. |
apps/server/src/git/GitManager.test.ts+1-1| ... | @@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr | ... | @@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr |
| 57 | import { | 57 | import { |
| 58 | ForgejoPullRequestSchema, | 58 | ForgejoPullRequestSchema, |
| 59 | toForgejoChangeRequest, | 59 | toForgejoChangeRequest, |
| 60 | } from "../sourceControl/forgejoPullRequests.ts"; | 60 | } from "@t3tools/source-control-forgejo/server/forgejoPullRequests"; |
| 61 | import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; | 61 | import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 62 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 62 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 63 | import * as ServerConfig from "../config.ts"; | 63 | import * as ServerConfig from "../config.ts"; |
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+1-1| ... | @@ -22,7 +22,7 @@ import type { | ... | @@ -22,7 +22,7 @@ import type { |
| 22 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 23 | 23 | ||
| 24 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 24 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 25 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | 25 | import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; |
| 26 | import { | 26 | import { |
| 27 | buildReviewThreads, | 27 | buildReviewThreads, |
| 28 | decodeCommentsJson, | 28 | decodeCommentsJson, |
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts deleted-614| ... | @@ -1,614 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as Result from "effect/Result"; | ||
| 4 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | ||
| 5 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 6 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; | ||
| 7 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | ||
| 8 | import { | ||
| 9 | PullRequestProviderError, | ||
| 10 | type ProviderChangeRequestDetail, | ||
| 11 | type ProviderRepositoryRef, | ||
| 12 | type PullRequestProviderApi, | ||
| 13 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 14 | import { | ||
| 15 | ForgejoPullRequest, | ||
| 16 | ForgejoRepository, | ||
| 17 | ForgejoUser, | ||
| 18 | ForgejoComment, | ||
| 19 | ForgejoReview, | ||
| 20 | ForgejoReviewComment, | ||
| 21 | ForgejoCommit, | ||
| 22 | ForgejoStatus, | ||
| 23 | ForgejoLabel, | ||
| 24 | ForgejoReaction, | ||
| 25 | FORGEJO_REACTIONS, | ||
| 26 | forgejoChangeRequest, | ||
| 27 | forgejoActor, | ||
| 28 | forgejoComment, | ||
| 29 | forgejoReview, | ||
| 30 | forgejoReviewThread, | ||
| 31 | forgejoCommit, | ||
| 32 | forgejoChecks, | ||
| 33 | forgejoReactions, | ||
| 34 | } from "./forgejoPullRequestJson.ts"; | ||
| 35 | |||
| 36 | const CAPABILITIES: PullRequestCapabilities = { | ||
| 37 | diff: true, | ||
| 38 | viewedFiles: "environment", | ||
| 39 | comment: true, | ||
| 40 | actions: ["merge", "close", "reopen", "update-branch"], | ||
| 41 | mergeMethods: ["merge", "squash", "rebase"], | ||
| 42 | updateMethods: ["merge", "rebase"], | ||
| 43 | search: false, | ||
| 44 | reactions: true, | ||
| 45 | labels: true, | ||
| 46 | // Forgejo's public API has no thread replies/resolution or draft conversion endpoint. | ||
| 47 | review: { | ||
| 48 | inlineComment: true, | ||
| 49 | reply: false, | ||
| 50 | resolve: false, | ||
| 51 | verdicts: ["comment", "approve", "request-changes"], | ||
| 52 | }, | ||
| 53 | reviewers: { request: true, listCandidates: true }, | ||
| 54 | edit: { changeRequest: true, comment: true }, | ||
| 55 | }; | ||
| 56 | const repoPath = (input: ProviderRepositoryRef) => | ||
| 57 | `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 58 | const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 59 | `${repoPath(input)}/pulls/${input.number}`; | ||
| 60 | const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 61 | `${repoPath(input)}/issues/${input.number}`; | ||
| 62 | // Review IDs differ from the issue-comment IDs used by Forgejo's reactions API. | ||
| 63 | const reviewCommentId = (review: typeof ForgejoReview.Type) => | ||
| 64 | /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1]; | ||
| 65 | |||
| 66 | export const make = Effect.gen(function* () { | ||
| 67 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 68 | const failure = (operation: string, detail: string, cause?: unknown) => | ||
| 69 | new PullRequestProviderError({ | ||
| 70 | provider: "forgejo", | ||
| 71 | operation, | ||
| 72 | reason: "failed", | ||
| 73 | detail, | ||
| 74 | ...(cause === undefined ? {} : { cause }), | ||
| 75 | }); | ||
| 76 | const request = (input: ForgejoCli.ForgejoApiInput) => | ||
| 77 | cli.api(input).pipe( | ||
| 78 | Effect.mapError( | ||
| 79 | (error) => | ||
| 80 | new PullRequestProviderError({ | ||
| 81 | provider: "forgejo", | ||
| 82 | operation: input.path, | ||
| 83 | detail: error.detail, | ||
| 84 | cause: error, | ||
| 85 | reason: | ||
| 86 | error.reason === "missing-cli" | ||
| 87 | ? "missing-tool" | ||
| 88 | : error.reason === "authentication" | ||
| 89 | ? "unauthenticated" | ||
| 90 | : error.reason === "rate-limit" | ||
| 91 | ? "rate-limited" | ||
| 92 | : error.reason === "not-found" | ||
| 93 | ? "not-found" | ||
| 94 | : "failed", | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | ); | ||
| 98 | const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>( | ||
| 99 | input: ForgejoCli.ForgejoApiInput, | ||
| 100 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 101 | ) { | ||
| 102 | const result = yield* request(input); | ||
| 103 | if (result.stdoutTruncated) | ||
| 104 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); | ||
| 105 | const decoded = decodeJsonResult(schema)(result.stdout); | ||
| 106 | return Result.isSuccess(decoded) | ||
| 107 | ? decoded.success | ||
| 108 | : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); | ||
| 109 | }); | ||
| 110 | const readArray = <A>( | ||
| 111 | input: ForgejoCli.ForgejoApiInput, | ||
| 112 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 113 | ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? [])); | ||
| 114 | const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>( | ||
| 115 | input: ForgejoCli.ForgejoApiInput, | ||
| 116 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 117 | index: number, | ||
| 118 | ) { | ||
| 119 | const result = yield* request({ | ||
| 120 | ...input, | ||
| 121 | path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`, | ||
| 122 | }); | ||
| 123 | if (result.stdoutTruncated) | ||
| 124 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); | ||
| 125 | const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout); | ||
| 126 | if (Result.isFailure(decoded)) | ||
| 127 | return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); | ||
| 128 | const rows = decoded.success ?? []; | ||
| 129 | const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; | ||
| 130 | return { | ||
| 131 | rows, | ||
| 132 | more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)), | ||
| 133 | }; | ||
| 134 | }); | ||
| 135 | // Use a stable, small page size that also works with Forgejo's default maximum of 50. | ||
| 136 | const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>( | ||
| 137 | input: ForgejoCli.ForgejoApiInput, | ||
| 138 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 139 | limit = 500, | ||
| 140 | ) { | ||
| 141 | const items: A[] = []; | ||
| 142 | for (let index = 1; items.length < limit; index++) { | ||
| 143 | const { rows, more } = yield* readPage(input, schema, index); | ||
| 144 | items.push(...rows); | ||
| 145 | if (!more) return { items, truncated: false }; | ||
| 146 | } | ||
| 147 | return { items, truncated: true }; | ||
| 148 | }); | ||
| 149 | const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid); | ||
| 150 | const getPull = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 151 | read( | ||
| 152 | { | ||
| 153 | cwd: input.cwd, | ||
| 154 | repository: input.repository, | ||
| 155 | host: input.host, | ||
| 156 | path: pullPath(input), | ||
| 157 | }, | ||
| 158 | ForgejoPullRequest, | ||
| 159 | ); | ||
| 160 | const getRepo = (input: ProviderRepositoryRef) => | ||
| 161 | read({ ...input, path: repoPath(input) }, ForgejoRepository); | ||
| 162 | const getViewer = (input: { | ||
| 163 | readonly cwd: string; | ||
| 164 | readonly repository?: string; | ||
| 165 | readonly host?: string; | ||
| 166 | }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login)); | ||
| 167 | const permissions = ( | ||
| 168 | repo: typeof ForgejoRepository.Type, | ||
| 169 | pr: typeof ForgejoPullRequest.Type, | ||
| 170 | viewer: string, | ||
| 171 | ): PullRequestViewerPermissions => { | ||
| 172 | const canWrite = repo.permissions?.push ?? false; | ||
| 173 | const canEdit = canWrite || pr.user?.login === viewer; | ||
| 174 | const active = !repo.archived; | ||
| 175 | return { | ||
| 176 | actions: active | ||
| 177 | ? CAPABILITIES.actions.filter((action) => | ||
| 178 | action === "merge" || action === "update-branch" ? canWrite : canEdit, | ||
| 179 | ) | ||
| 180 | : [], | ||
| 181 | comment: active && (!pr.is_locked || canWrite), | ||
| 182 | resolve: false, | ||
| 183 | verdicts: active | ||
| 184 | ? pr.user?.login === viewer | ||
| 185 | ? ["comment"] | ||
| 186 | : CAPABILITIES.review.verdicts | ||
| 187 | : [], | ||
| 188 | requestReviewers: active && canEdit, | ||
| 189 | labels: active && canWrite, | ||
| 190 | updateMethods: | ||
| 191 | active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [], | ||
| 192 | }; | ||
| 193 | }; | ||
| 194 | const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* ( | ||
| 195 | input: ProviderRepositoryRef & { readonly number: number }, | ||
| 196 | ) { | ||
| 197 | const [repo, pr, viewer] = yield* Effect.all( | ||
| 198 | [getRepo(input), getPull(input), getViewer(input)], | ||
| 199 | { concurrency: 3 }, | ||
| 200 | ); | ||
| 201 | return permissions(repo, pr, viewer); | ||
| 202 | }); | ||
| 203 | const unsupported = (operation: string) => | ||
| 204 | Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); | ||
| 205 | const provider: PullRequestProviderApi = { | ||
| 206 | kind: "forgejo", | ||
| 207 | capabilities: CAPABILITIES, | ||
| 208 | getViewer, | ||
| 209 | listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( | ||
| 210 | function* (input) { | ||
| 211 | const offset = input.cursor?.delivered ?? 0; | ||
| 212 | const items: ReturnType<typeof forgejoChangeRequest>[] = []; | ||
| 213 | const state = input.state === "merged" ? "closed" : input.state; | ||
| 214 | const query = { | ||
| 215 | ...input, | ||
| 216 | path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`, | ||
| 217 | }; | ||
| 218 | // Self-hosted servers may cap pages below 50. Establish their actual page size before | ||
| 219 | // translating the service's row offset into an API page number. | ||
| 220 | const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1); | ||
| 221 | const pageSize = first.rows.length || 50; | ||
| 222 | const firstIndex = Math.floor(offset / pageSize) + 1; | ||
| 223 | let consumed = 0; | ||
| 224 | let more = true; | ||
| 225 | for (let index = firstIndex; more && consumed < input.limit; index++) { | ||
| 226 | const result = | ||
| 227 | index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index); | ||
| 228 | const rows = result.rows; | ||
| 229 | const start = index === firstIndex ? offset % pageSize : 0; | ||
| 230 | const countBefore = consumed; | ||
| 231 | for (const row of rows.slice(start)) { | ||
| 232 | if (consumed >= input.limit) break; | ||
| 233 | consumed++; | ||
| 234 | if (row) items.push(forgejoChangeRequest(row)); | ||
| 235 | } | ||
| 236 | more = result.more || rows.length - start > consumed - countBefore; | ||
| 237 | } | ||
| 238 | return { items, truncated: more, continues: true, cursorAdvance: consumed }; | ||
| 239 | }, | ||
| 240 | ), | ||
| 241 | getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)), | ||
| 242 | getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")( | ||
| 243 | function* (input) { | ||
| 244 | const pr = yield* getPull(input); | ||
| 245 | const statuses = yield* page( | ||
| 246 | { | ||
| 247 | ...input, | ||
| 248 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, | ||
| 249 | }, | ||
| 250 | ForgejoStatus, | ||
| 251 | ); | ||
| 252 | return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) }; | ||
| 253 | }, | ||
| 254 | ), | ||
| 255 | getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) { | ||
| 256 | const [pr, repo, viewer] = yield* Effect.all( | ||
| 257 | [getPull(input), getRepo(input), getViewer(input)], | ||
| 258 | { concurrency: 3 }, | ||
| 259 | ); | ||
| 260 | const statuses = yield* page( | ||
| 261 | { | ||
| 262 | ...input, | ||
| 263 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, | ||
| 264 | }, | ||
| 265 | ForgejoStatus, | ||
| 266 | ); | ||
| 267 | return { | ||
| 268 | ...forgejoChangeRequest(pr), | ||
| 269 | body: pr.body ?? "", | ||
| 270 | changedFiles: pr.changed_files ?? 0, | ||
| 271 | reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { | ||
| 272 | const actor = forgejoActor(user); | ||
| 273 | return actor ? [actor] : []; | ||
| 274 | }), | ||
| 275 | checks: forgejoChecks(statuses.items), | ||
| 276 | baseComparison: !pr.merge_base | ||
| 277 | ? "unknown" | ||
| 278 | : pr.merge_base === pr.base.sha | ||
| 279 | ? "up-to-date" | ||
| 280 | : "behind", | ||
| 281 | viewerPermissions: permissions(repo, pr, viewer), | ||
| 282 | mergeCapabilities: { | ||
| 283 | merge: repo.allow_merge_commits ?? true, | ||
| 284 | squash: repo.allow_squash_merge ?? true, | ||
| 285 | rebase: repo.allow_rebase ?? true, | ||
| 286 | }, | ||
| 287 | } satisfies ProviderChangeRequestDetail; | ||
| 288 | }), | ||
| 289 | getViewerPermissions: getPermissions, | ||
| 290 | getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")( | ||
| 291 | function* (input) { | ||
| 292 | const [comments, reviews, commits, reactions, viewer] = yield* Effect.all( | ||
| 293 | [ | ||
| 294 | // Issue comments ignore page/limit; fetch this unpaginated endpoint once. | ||
| 295 | readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe( | ||
| 296 | Effect.map((items) => ({ | ||
| 297 | items: items.slice(0, 500), | ||
| 298 | truncated: items.length > 500, | ||
| 299 | })), | ||
| 300 | ), | ||
| 301 | page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview), | ||
| 302 | page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit), | ||
| 303 | page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction), | ||
| 304 | getViewer(input), | ||
| 305 | ], | ||
| 306 | { concurrency: 5 }, | ||
| 307 | ); | ||
| 308 | const reviewComments = yield* Effect.forEach( | ||
| 309 | reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"), | ||
| 310 | (review) => | ||
| 311 | readArray( | ||
| 312 | { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` }, | ||
| 313 | ForgejoReviewComment, | ||
| 314 | ), | ||
| 315 | { concurrency: 4 }, | ||
| 316 | ); | ||
| 317 | const allInline = reviewComments.flat(); | ||
| 318 | const inline = allInline.slice(0, 500); | ||
| 319 | const entries = [ | ||
| 320 | ...comments.items.map((comment) => ({ | ||
| 321 | comment: forgejoComment(comment), | ||
| 322 | reactionId: String(comment.id), | ||
| 323 | })), | ||
| 324 | ...reviews.items | ||
| 325 | .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW") | ||
| 326 | .map((review) => ({ | ||
| 327 | comment: forgejoReview(review), | ||
| 328 | reactionId: reviewCommentId(review), | ||
| 329 | })), | ||
| 330 | ...inline.map((comment) => ({ | ||
| 331 | comment: { | ||
| 332 | ...forgejoComment(comment), | ||
| 333 | kind: "review-comment" as const, | ||
| 334 | path: comment.path, | ||
| 335 | }, | ||
| 336 | reactionId: String(comment.id), | ||
| 337 | })), | ||
| 338 | ]; | ||
| 339 | const enriched = yield* Effect.forEach( | ||
| 340 | entries, | ||
| 341 | ({ comment, reactionId }) => | ||
| 342 | (reactionId === undefined | ||
| 343 | ? Effect.succeed([]) | ||
| 344 | : readArray( | ||
| 345 | { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` }, | ||
| 346 | ForgejoReaction, | ||
| 347 | ) | ||
| 348 | ).pipe( | ||
| 349 | Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })), | ||
| 350 | ), | ||
| 351 | { concurrency: 4 }, | ||
| 352 | ); | ||
| 353 | const byId = new Map(enriched.map((comment) => [comment.id, comment])); | ||
| 354 | const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); | ||
| 355 | return { | ||
| 356 | comments: timeline, | ||
| 357 | commentCount: timeline.length, | ||
| 358 | commentsTruncated: | ||
| 359 | comments.truncated || reviews.truncated || allInline.length > inline.length, | ||
| 360 | reviewThreads: inline.map((comment) => ({ | ||
| 361 | ...forgejoReviewThread(comment), | ||
| 362 | comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)], | ||
| 363 | })), | ||
| 364 | commits: commits.items.map(forgejoCommit), | ||
| 365 | reactions: forgejoReactions(reactions.items, viewer), | ||
| 366 | }; | ||
| 367 | }, | ||
| 368 | ), | ||
| 369 | getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) { | ||
| 370 | const result = yield* request({ ...input, path: `${pullPath(input)}.diff` }); | ||
| 371 | if (result.stdoutTruncated) { | ||
| 372 | return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit."); | ||
| 373 | } | ||
| 374 | const revisions = new Map(parseDiffFileRevisions(result.stdout)); | ||
| 375 | for (const path of input.paths) { | ||
| 376 | if (!revisions.has(path)) revisions.set(path, ""); | ||
| 377 | } | ||
| 378 | return { revisions, complete: true }; | ||
| 379 | }), | ||
| 380 | getDiff: (input) => | ||
| 381 | request({ | ||
| 382 | ...input, | ||
| 383 | path: input.commit | ||
| 384 | ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff` | ||
| 385 | : `${pullPath(input)}.diff`, | ||
| 386 | }).pipe( | ||
| 387 | Effect.map((result) => ({ | ||
| 388 | patch: result.stdout, | ||
| 389 | truncated: result.stdoutTruncated, | ||
| 390 | nextCursor: null, | ||
| 391 | })), | ||
| 392 | ), | ||
| 393 | getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")( | ||
| 394 | function* (input) { | ||
| 395 | const pr = yield* getPull(input); | ||
| 396 | const commit = input.commit | ||
| 397 | ? yield* read( | ||
| 398 | { | ||
| 399 | ...input, | ||
| 400 | path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`, | ||
| 401 | }, | ||
| 402 | ForgejoCommit, | ||
| 403 | ) | ||
| 404 | : null; | ||
| 405 | const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha; | ||
| 406 | const newRef = input.commit ?? pr.head.sha; | ||
| 407 | const content = (repository: string, ref: string, path: string) => | ||
| 408 | read( | ||
| 409 | { | ||
| 410 | ...input, | ||
| 411 | repository, | ||
| 412 | path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, | ||
| 413 | }, | ||
| 414 | Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }), | ||
| 415 | ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8"))); | ||
| 416 | const [oldContents, newContents] = yield* Effect.all( | ||
| 417 | [ | ||
| 418 | input.changeType === "new" || !oldRef | ||
| 419 | ? Effect.succeed("") | ||
| 420 | : content(input.repository, oldRef, input.oldPath), | ||
| 421 | input.changeType === "deleted" | ||
| 422 | ? Effect.succeed("") | ||
| 423 | : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath), | ||
| 424 | ], | ||
| 425 | { concurrency: 2 }, | ||
| 426 | ); | ||
| 427 | return { oldContents, newContents }; | ||
| 428 | }, | ||
| 429 | ), | ||
| 430 | runAction: (input) => { | ||
| 431 | switch (input.action) { | ||
| 432 | case "merge": | ||
| 433 | return write({ | ||
| 434 | ...input, | ||
| 435 | path: `${pullPath(input)}/merge`, | ||
| 436 | method: "POST", | ||
| 437 | body: { Do: input.mergeMethod ?? "merge" }, | ||
| 438 | }); | ||
| 439 | case "close": | ||
| 440 | case "reopen": | ||
| 441 | return write({ | ||
| 442 | ...input, | ||
| 443 | path: pullPath(input), | ||
| 444 | method: "PATCH", | ||
| 445 | body: { state: input.action === "close" ? "closed" : "open" }, | ||
| 446 | }); | ||
| 447 | case "update-branch": | ||
| 448 | return write({ | ||
| 449 | ...input, | ||
| 450 | path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`, | ||
| 451 | method: "POST", | ||
| 452 | }); | ||
| 453 | default: | ||
| 454 | return unsupported(input.action); | ||
| 455 | } | ||
| 456 | }, | ||
| 457 | updateChangeRequest: (input) => | ||
| 458 | write({ | ||
| 459 | ...input, | ||
| 460 | path: pullPath(input), | ||
| 461 | method: "PATCH", | ||
| 462 | body: { | ||
| 463 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 464 | ...(input.body === undefined ? {} : { body: input.body }), | ||
| 465 | }, | ||
| 466 | }), | ||
| 467 | comment: (input) => | ||
| 468 | write({ | ||
| 469 | ...input, | ||
| 470 | path: `${issuePath(input)}/comments`, | ||
| 471 | method: "POST", | ||
| 472 | body: { body: input.body }, | ||
| 473 | }), | ||
| 474 | updateComment: (input) => | ||
| 475 | write({ | ||
| 476 | ...input, | ||
| 477 | path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`, | ||
| 478 | method: "PATCH", | ||
| 479 | body: { body: input.body }, | ||
| 480 | }), | ||
| 481 | submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) { | ||
| 482 | const pr = yield* getPull(input); | ||
| 483 | yield* write({ | ||
| 484 | ...input, | ||
| 485 | path: `${pullPath(input)}/reviews`, | ||
| 486 | method: "POST", | ||
| 487 | body: { | ||
| 488 | event: | ||
| 489 | input.verdict === "approve" | ||
| 490 | ? "APPROVED" | ||
| 491 | : input.verdict === "request-changes" | ||
| 492 | ? "REQUEST_CHANGES" | ||
| 493 | : "COMMENT", | ||
| 494 | body: input.body, | ||
| 495 | commit_id: pr.head.sha, | ||
| 496 | comments: input.comments.map((comment) => { | ||
| 497 | const position = comment.position; | ||
| 498 | const old = | ||
| 499 | position.kind === "deleted" || | ||
| 500 | (position.kind === "context" && position.side === "left"); | ||
| 501 | return { | ||
| 502 | path: old ? (comment.oldPath ?? comment.path) : comment.path, | ||
| 503 | body: comment.body, | ||
| 504 | old_position: old ? position.oldLine : 0, | ||
| 505 | new_position: old ? 0 : position.newLine, | ||
| 506 | }; | ||
| 507 | }), | ||
| 508 | }, | ||
| 509 | }); | ||
| 510 | }), | ||
| 511 | listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")( | ||
| 512 | function* (input) { | ||
| 513 | const [pr, users] = yield* Effect.all( | ||
| 514 | [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)], | ||
| 515 | { concurrency: 2 }, | ||
| 516 | ); | ||
| 517 | return { | ||
| 518 | candidates: users.items | ||
| 519 | .filter((user) => user.login !== pr.user?.login) | ||
| 520 | .flatMap((user) => { | ||
| 521 | const actor = forgejoActor(user); | ||
| 522 | return actor | ||
| 523 | ? [ | ||
| 524 | { | ||
| 525 | ...actor, | ||
| 526 | id: user.login, | ||
| 527 | kind: "user" as const, | ||
| 528 | isRequested: | ||
| 529 | pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ?? | ||
| 530 | false, | ||
| 531 | }, | ||
| 532 | ] | ||
| 533 | : []; | ||
| 534 | }), | ||
| 535 | truncated: users.truncated, | ||
| 536 | }; | ||
| 537 | }, | ||
| 538 | ), | ||
| 539 | setReviewerRequest: (input) => | ||
| 540 | write({ | ||
| 541 | ...input, | ||
| 542 | path: `${pullPath(input)}/requested_reviewers`, | ||
| 543 | method: input.requested ? "POST" : "DELETE", | ||
| 544 | body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) }, | ||
| 545 | }), | ||
| 546 | listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")( | ||
| 547 | function* (input) { | ||
| 548 | const [pr, labels] = yield* Effect.all( | ||
| 549 | [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)], | ||
| 550 | { concurrency: 2 }, | ||
| 551 | ); | ||
| 552 | return { | ||
| 553 | candidates: labels.items.map((label) => ({ | ||
| 554 | name: label.name, | ||
| 555 | color: label.color ?? null, | ||
| 556 | description: label.description ?? null, | ||
| 557 | isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false, | ||
| 558 | })), | ||
| 559 | truncated: labels.truncated, | ||
| 560 | }; | ||
| 561 | }, | ||
| 562 | ), | ||
| 563 | setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) { | ||
| 564 | const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); | ||
| 565 | const selected = labels.items.filter((label) => input.labels.includes(label.name)); | ||
| 566 | if (selected.length !== input.labels.length) | ||
| 567 | return yield* failure("setLabels", "One or more requested labels could not be found."); | ||
| 568 | if (input.applied) | ||
| 569 | yield* write({ | ||
| 570 | ...input, | ||
| 571 | path: `${issuePath(input)}/labels`, | ||
| 572 | method: "POST", | ||
| 573 | body: { labels: selected.map((label) => label.id) }, | ||
| 574 | }); | ||
| 575 | else | ||
| 576 | yield* Effect.forEach( | ||
| 577 | selected, | ||
| 578 | (label) => | ||
| 579 | write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }), | ||
| 580 | { concurrency: 1 }, | ||
| 581 | ); | ||
| 582 | }), | ||
| 583 | setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) { | ||
| 584 | let commentId = input.subjectId; | ||
| 585 | if (commentId?.startsWith("review:")) { | ||
| 586 | const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1]; | ||
| 587 | if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID."); | ||
| 588 | const review = yield* read( | ||
| 589 | { ...input, path: `${pullPath(input)}/reviews/${reviewId}` }, | ||
| 590 | ForgejoReview, | ||
| 591 | ); | ||
| 592 | commentId = reviewCommentId(review); | ||
| 593 | if (!commentId) | ||
| 594 | return yield* failure( | ||
| 595 | "setReaction", | ||
| 596 | "Forgejo did not return a comment ID for this review.", | ||
| 597 | ); | ||
| 598 | } | ||
| 599 | if (commentId && !/^[1-9]\d*$/.test(commentId)) | ||
| 600 | return yield* failure("setReaction", "Invalid Forgejo comment ID."); | ||
| 601 | yield* write({ | ||
| 602 | ...input, | ||
| 603 | path: commentId | ||
| 604 | ? `${repoPath(input)}/issues/comments/${commentId}/reactions` | ||
| 605 | : `${issuePath(input)}/reactions`, | ||
| 606 | method: input.reacted ? "POST" : "DELETE", | ||
| 607 | body: { content: FORGEJO_REACTIONS[input.content] }, | ||
| 608 | }); | ||
| 609 | }), | ||
| 610 | replyToThread: () => unsupported("thread replies"), | ||
| 611 | setThreadResolution: () => unsupported("thread resolution"), | ||
| 612 | }; | ||
| 613 | return provider; | ||
| 614 | }); | ||
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+13-9| ... | @@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; | ... | @@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 8 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | 8 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 9 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 9 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 10 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; | ||
| 11 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | ||
| 12 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | 10 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; |
| 13 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | 11 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; |
| 14 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | 12 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| ... | @@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | ... | @@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 17 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; | 15 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; |
| 18 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | 16 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; |
| 19 | import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; | 17 | import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; |
| 18 | import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; | ||
| 20 | import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; | 19 | import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 21 | 20 | ||
| 22 | export class PullRequestProviderRegistry extends Context.Service< | 21 | export class PullRequestProviderRegistry extends Context.Service< |
| ... | @@ -45,16 +44,21 @@ export function fromProviders( | ... | @@ -45,16 +44,21 @@ export function fromProviders( |
| 45 | * | 44 | * |
| 46 | * @public Service construction is part of the canonical Effect module API. | 45 | * @public Service construction is part of the canonical Effect module API. |
| 47 | */ | 46 | */ |
| 48 | export const make = Effect.map( | 47 | export const make = Effect.gen(function* () { |
| 49 | Effect.all([ | 48 | const providers = yield* Effect.all([ |
| 50 | GitHubPullRequestProvider.make, | 49 | GitHubPullRequestProvider.make, |
| 51 | GitLabPullRequestProvider.make, | 50 | GitLabPullRequestProvider.make, |
| 52 | ForgejoPullRequestProvider.make, | ||
| 53 | BitbucketPullRequestProvider.make, | 51 | BitbucketPullRequestProvider.make, |
| 54 | AzureDevOpsPullRequestProvider.make, | 52 | AzureDevOpsPullRequestProvider.make, |
| 55 | ]), | 53 | ]); |
| 56 | fromProviders, | 54 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 57 | ); | 55 | driver.make.pipe(Effect.map((instance) => instance.pullRequests)), |
| 56 | ); | ||
| 57 | return fromProviders([ | ||
| 58 | ...providers, | ||
| 59 | ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), | ||
| 60 | ]); | ||
| 61 | }); | ||
| 58 | 62 | ||
| 59 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( | 63 | export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 60 | Layer.provide( | 64 | Layer.provide( |
| ... | @@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( | ... | @@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 66 | ), | 70 | ), |
| 67 | ), | 71 | ), |
| 68 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), | 72 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), |
| 69 | Layer.provide(ForgejoCli.layer), | 73 | Layer.provide(BuiltInDrivers.layer), |
| 70 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), | 74 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), |
| 71 | Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), | 75 | Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), |
| 72 | ); | 76 | ); |
apps/server/src/pullRequest/PullRequestService.test.ts+2-2| ... | @@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t | ... | @@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t |
| 30 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | 30 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 31 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 31 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 32 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | 32 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 33 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; | 33 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 34 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | 34 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; |
| 35 | import { | 35 | import { |
| 36 | PullRequestProviderError, | 36 | PullRequestProviderError, |
| 37 | type ProviderChangeRequest, | 37 | type ProviderChangeRequest, |
apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts deleted-280| ... | @@ -1,280 +0,0 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | ||
| 4 | |||
| 5 | function patchOf(...lines: ReadonlyArray<string>): string { | ||
| 6 | return `${lines.join("\n")}\n`; | ||
| 7 | } | ||
| 8 | |||
| 9 | describe("parseDiffFileRevisions", () => { | ||
| 10 | it("reads the head id of a changed file off its index line", () => { | ||
| 11 | const revisions = parseDiffFileRevisions( | ||
| 12 | patchOf( | ||
| 13 | "diff --git a/src/a.ts b/src/a.ts", | ||
| 14 | "index 7f2aa0ab6..b4a2a7c9a 100644", | ||
| 15 | "--- a/src/a.ts", | ||
| 16 | "+++ b/src/a.ts", | ||
| 17 | "@@ -1 +1 @@", | ||
| 18 | "-a", | ||
| 19 | "+b", | ||
| 20 | ), | ||
| 21 | ); | ||
| 22 | |||
| 23 | assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("names a deletion by the path it had, which is the one still on screen", () => { | ||
| 27 | const revisions = parseDiffFileRevisions( | ||
| 28 | patchOf( | ||
| 29 | "diff --git a/gone.ts b/gone.ts", | ||
| 30 | "deleted file mode 100644", | ||
| 31 | "index 1111111..0000000", | ||
| 32 | "--- a/gone.ts", | ||
| 33 | "+++ /dev/null", | ||
| 34 | "@@ -1 +0,0 @@", | ||
| 35 | "-x", | ||
| 36 | ), | ||
| 37 | ); | ||
| 38 | |||
| 39 | assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("names a rename by where it moved to", () => { | ||
| 43 | const revisions = parseDiffFileRevisions( | ||
| 44 | patchOf( | ||
| 45 | "diff --git a/old.ts b/new.ts", | ||
| 46 | "similarity index 90%", | ||
| 47 | "rename from old.ts", | ||
| 48 | "rename to new.ts", | ||
| 49 | "index 2222222..3333333 100644", | ||
| 50 | "--- a/old.ts", | ||
| 51 | "+++ b/new.ts", | ||
| 52 | "@@ -1 +1 @@", | ||
| 53 | "-a", | ||
| 54 | "+b", | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | |||
| 58 | assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("names a rename that changed nothing, which states no paths of its own", () => { | ||
| 62 | const revisions = parseDiffFileRevisions( | ||
| 63 | patchOf( | ||
| 64 | "diff --git a/old.ts b/new.ts", | ||
| 65 | "similarity index 100%", | ||
| 66 | "rename from old.ts", | ||
| 67 | "rename to new.ts", | ||
| 68 | "index 2222222..2222222 100644", | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | |||
| 72 | assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("leaves out a file Bitbucket excluded, which it gives no index line for", () => { | ||
| 76 | const revisions = parseDiffFileRevisions( | ||
| 77 | patchOf( | ||
| 78 | "diff --git a/package.json b/package.json", | ||
| 79 | "index 7f2aa0ab6..b4a2a7c9a 100644", | ||
| 80 | "--- a/package.json", | ||
| 81 | "+++ b/package.json", | ||
| 82 | "@@ -1 +1 @@", | ||
| 83 | '- "x": "1"', | ||
| 84 | '+ "x": "2"', | ||
| 85 | "diff --git a/yarn.lock b/yarn.lock", | ||
| 86 | 'File excluded by pattern "yarn.lock"', | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | |||
| 90 | assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("stops reading headers at the first hunk, so content cannot pose as one", () => { | ||
| 94 | const revisions = parseDiffFileRevisions( | ||
| 95 | patchOf( | ||
| 96 | "diff --git a/notes.md b/notes.md", | ||
| 97 | "index aaaaaaa..bbbbbbb 100644", | ||
| 98 | "--- a/notes.md", | ||
| 99 | "+++ b/notes.md", | ||
| 100 | "@@ -1,2 +1,2 @@", | ||
| 101 | "--- a/decoy.ts", | ||
| 102 | "+++ b/decoy.ts", | ||
| 103 | "+index ccccccc..ddddddd 100644", | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | |||
| 107 | assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("splits a header whose paths contain the separator, by the sides agreeing", () => { | ||
| 111 | const revisions = parseDiffFileRevisions( | ||
| 112 | patchOf( | ||
| 113 | "diff --git a/one b/two.ts b/one b/two.ts", | ||
| 114 | "index eeeeeee..fffffff 100644", | ||
| 115 | "@@ -1 +1 @@", | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | |||
| 119 | assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("leaves out an added file that Bitbucket sent no index line for", () => { | ||
| 123 | const revisions = parseDiffFileRevisions( | ||
| 124 | patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"), | ||
| 125 | ); | ||
| 126 | |||
| 127 | assert.strictEqual(revisions.size, 0); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("reads nothing out of an empty patch", () => { | ||
| 131 | assert.strictEqual(parseDiffFileRevisions("").size, 0); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("reads a name git had to quote, here one holding a tab", () => { | ||
| 135 | const revisions = parseDiffFileRevisions( | ||
| 136 | patchOf( | ||
| 137 | 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"', | ||
| 138 | "index 4444444..5555555 100644", | ||
| 139 | '--- "a/we\\tird.ts"', | ||
| 140 | '+++ "b/we\\tird.ts"', | ||
| 141 | "@@ -1 +1 @@", | ||
| 142 | "-a", | ||
| 143 | "+b", | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("rejoins the octal bytes git writes for a name outside ASCII", () => { | ||
| 151 | const revisions = parseDiffFileRevisions( | ||
| 152 | patchOf( | ||
| 153 | 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"', | ||
| 154 | "index 6666666..7777777 100644", | ||
| 155 | "@@ -1 +1 @@", | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | |||
| 159 | assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("splits a rename header where git quoted only the side that needed it", () => { | ||
| 163 | const revisions = parseDiffFileRevisions( | ||
| 164 | patchOf( | ||
| 165 | 'diff --git a/old.ts "b/new\\tname.ts"', | ||
| 166 | "similarity index 90%", | ||
| 167 | "rename from old.ts", | ||
| 168 | 'rename to "new\\tname.ts"', | ||
| 169 | "index 8888888..9999999 100644", | ||
| 170 | "--- a/old.ts", | ||
| 171 | '+++ "b/new\\tname.ts"', | ||
| 172 | "@@ -1 +1 @@", | ||
| 173 | "-a", | ||
| 174 | "+b", | ||
| 175 | ), | ||
| 176 | ); | ||
| 177 | |||
| 178 | assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("names a quoted rename that changed nothing, which states no paths of its own", () => { | ||
| 182 | const revisions = parseDiffFileRevisions( | ||
| 183 | patchOf( | ||
| 184 | 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', | ||
| 185 | "similarity index 100%", | ||
| 186 | 'rename from "old\\tname.ts"', | ||
| 187 | 'rename to "new\\tname.ts"', | ||
| 188 | "index abcabca..abcabca 100644", | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 192 | assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("keeps a character from outside the basic plane that git left unescaped", () => { | ||
| 196 | // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header | ||
| 197 | // for the tab. Encoding what it left one unit at a time would split the pair into two halves. | ||
| 198 | const revisions = parseDiffFileRevisions( | ||
| 199 | patchOf( | ||
| 200 | 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"', | ||
| 201 | "index aaaaaaa..bbbbbbb 100644", | ||
| 202 | "@@ -1 +1 @@", | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("drops the tab git ends a name holding a space with", () => { | ||
| 210 | const revisions = parseDiffFileRevisions( | ||
| 211 | patchOf( | ||
| 212 | "diff --git a/with space.txt b/with space.txt", | ||
| 213 | "index 1111111..6178079 100644", | ||
| 214 | "--- a/with space.txt\t", | ||
| 215 | "+++ b/with space.txt\t", | ||
| 216 | "@@ -1 +1 @@", | ||
| 217 | "-a", | ||
| 218 | "+b", | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | |||
| 222 | assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("drops that tab from a quoted name too, where it lands past the closing quote", () => { | ||
| 226 | const revisions = parseDiffFileRevisions( | ||
| 227 | patchOf( | ||
| 228 | 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"', | ||
| 229 | "index 2222222..7777777 100644", | ||
| 230 | '--- "a/we ird\\tname.ts"\t', | ||
| 231 | '+++ "b/we ird\\tname.ts"\t', | ||
| 232 | "@@ -1 +1 @@", | ||
| 233 | "-a", | ||
| 234 | "+b", | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | |||
| 238 | assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("reads a deletion whose name holds a space, tab and all", () => { | ||
| 242 | const revisions = parseDiffFileRevisions( | ||
| 243 | patchOf( | ||
| 244 | "diff --git a/with space.txt b/with space.txt", | ||
| 245 | "deleted file mode 100644", | ||
| 246 | "index 3333333..0000000", | ||
| 247 | "--- a/with space.txt\t", | ||
| 248 | "+++ /dev/null", | ||
| 249 | "@@ -1 +0,0 @@", | ||
| 250 | "-a", | ||
| 251 | ), | ||
| 252 | ); | ||
| 253 | |||
| 254 | assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]); | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("drops the timestamp other producers of the format write past that tab", () => { | ||
| 258 | const revisions = parseDiffFileRevisions( | ||
| 259 | patchOf( | ||
| 260 | "diff --git a/stamped.ts b/stamped.ts", | ||
| 261 | "index 4444444..5555555 100644", | ||
| 262 | "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000", | ||
| 263 | "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000", | ||
| 264 | "@@ -1 +1 @@", | ||
| 265 | "-a", | ||
| 266 | "+b", | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | |||
| 270 | assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]); | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("splits an unquoted header whose names hold a space, by the sides agreeing", () => { | ||
| 274 | const revisions = parseDiffFileRevisions( | ||
| 275 | patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"), | ||
| 276 | ); | ||
| 277 | |||
| 278 | assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]); | ||
| 279 | }); | ||
| 280 | }); | ||
apps/server/src/pullRequest/bitbucketDiffRevisions.ts deleted-129| ... | @@ -1,129 +0,0 @@ | ||
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | ||
| 2 | |||
| 3 | const ENTRY = "diff --git "; | ||
| 4 | const QUOTE = '"'; | ||
| 5 | |||
| 6 | interface Entry { | ||
| 7 | oldPath: string | null; | ||
| 8 | newPath: string | null; | ||
| 9 | deleted: boolean; | ||
| 10 | revision: string | null; | ||
| 11 | /** Past the first hunk header every line is content, and content can start like a header. */ | ||
| 12 | inBody: boolean; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** Where a quoted name closes, given git escapes every quote the name itself holds. */ | ||
| 16 | function quotedEnd(rest: string): number { | ||
| 17 | for (let at = 1; at < rest.length; at += 1) { | ||
| 18 | const char = rest.charAt(at); | ||
| 19 | if (char === "\\") { | ||
| 20 | at += 1; | ||
| 21 | continue; | ||
| 22 | } | ||
| 23 | if (char === QUOTE) return at; | ||
| 24 | } | ||
| 25 | return -1; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** | ||
| 29 | * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the | ||
| 30 | * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with | ||
| 31 | * that tab escaped, so the first literal tab is never part of what the file is called. | ||
| 32 | */ | ||
| 33 | function sidePath(rest: string, prefix: string): string | null { | ||
| 34 | const tab = rest.indexOf("\t"); | ||
| 35 | const token = tab === -1 ? rest : rest.slice(0, tab); | ||
| 36 | if (token === "/dev/null") return null; | ||
| 37 | const path = unquoteGitPatchPath(token); | ||
| 38 | return path.startsWith(prefix) ? path.slice(prefix.length) : path; | ||
| 39 | } | ||
| 40 | |||
| 41 | function headerSide(token: string, prefix: string): string | null { | ||
| 42 | const path = unquoteGitPatchPath(token); | ||
| 43 | return path.startsWith(prefix) ? path.slice(prefix.length) : null; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** | ||
| 47 | * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one | ||
| 48 | * two` can split in more than one place, so the split leaving both sides equal wins; a rename | ||
| 49 | * (the only case where sides differ) states its names on separate lines instead. A quoted name | ||
| 50 | * ends at its own closing quote and needs none of that guessing. | ||
| 51 | */ | ||
| 52 | function headerPaths(rest: string): readonly [string | null, string | null] { | ||
| 53 | if (rest.startsWith(QUOTE)) { | ||
| 54 | const end = quotedEnd(rest); | ||
| 55 | if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null]; | ||
| 56 | return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")]; | ||
| 57 | } | ||
| 58 | if (rest.endsWith(QUOTE)) { | ||
| 59 | const opens = rest.indexOf(QUOTE); | ||
| 60 | if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null]; | ||
| 61 | return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")]; | ||
| 62 | } | ||
| 63 | if (!rest.startsWith("a/")) return [null, null]; | ||
| 64 | const splits: Array<number> = []; | ||
| 65 | for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at); | ||
| 66 | const chosen = | ||
| 67 | splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ?? | ||
| 68 | (splits.length === 1 ? splits[0] : undefined); | ||
| 69 | return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)]; | ||
| 70 | } | ||
| 71 | |||
| 72 | /** The right-hand id of `index <before>..<after> <mode>`. */ | ||
| 73 | function headRevision(rest: string): string | null { | ||
| 74 | const gap = rest.indexOf(".."); | ||
| 75 | if (gap === -1) return null; | ||
| 76 | const after = rest.slice(gap + 2); | ||
| 77 | const end = after.indexOf(" "); | ||
| 78 | const head = end === -1 ? after : after.slice(0, end); | ||
| 79 | return head.length === 0 ? null : head; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** | ||
| 83 | * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>` | ||
| 84 | * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except | ||
| 85 | * for a deletion where only the old name exists. An entry with no `index` line (most often one | ||
| 86 | * Bitbucket excluded by pattern) is left out. | ||
| 87 | */ | ||
| 88 | export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> { | ||
| 89 | const revisions = new Map<string, string>(); | ||
| 90 | let entry: Entry | null = null; | ||
| 91 | |||
| 92 | const close = () => { | ||
| 93 | if (entry === null) return; | ||
| 94 | const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath); | ||
| 95 | if (path !== null && path.length > 0 && entry.revision !== null) { | ||
| 96 | revisions.set(path, entry.revision); | ||
| 97 | } | ||
| 98 | entry = null; | ||
| 99 | }; | ||
| 100 | |||
| 101 | for (const line of patch.split("\n")) { | ||
| 102 | if (line.startsWith(ENTRY)) { | ||
| 103 | close(); | ||
| 104 | const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length)); | ||
| 105 | entry = { oldPath, newPath, deleted: false, revision: null, inBody: false }; | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | if (entry === null || entry.inBody) continue; | ||
| 109 | if (line.startsWith("@@")) { | ||
| 110 | entry.inBody = true; | ||
| 111 | } else if (line.startsWith("index ")) { | ||
| 112 | entry.revision = headRevision(line.slice("index ".length)); | ||
| 113 | } else if (line.startsWith("deleted file mode")) { | ||
| 114 | entry.deleted = true; | ||
| 115 | } else if (line.startsWith("rename from ")) { | ||
| 116 | entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length)); | ||
| 117 | } else if (line.startsWith("rename to ")) { | ||
| 118 | entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length)); | ||
| 119 | } else if (line.startsWith("--- ")) { | ||
| 120 | entry.oldPath = sidePath(line.slice(4), "a/"); | ||
| 121 | } else if (line.startsWith("+++ ")) { | ||
| 122 | const side = sidePath(line.slice(4), "b/"); | ||
| 123 | entry.newPath = side; | ||
| 124 | if (side === null) entry.deleted = true; | ||
| 125 | } | ||
| 126 | } | ||
| 127 | close(); | ||
| 128 | return revisions; | ||
| 129 | } | ||
apps/server/src/pullRequest/forgejoPullRequestJson.ts deleted-262| ... | @@ -1,262 +0,0 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import type { | ||
| 5 | PullRequestActor, | ||
| 6 | PullRequestCheck, | ||
| 7 | PullRequestComment, | ||
| 8 | PullRequestCommit, | ||
| 9 | PullRequestReaction, | ||
| 10 | PullRequestReactionContent, | ||
| 11 | PullRequestReviewThread, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 14 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | ||
| 15 | |||
| 16 | export const ForgejoUser = Schema.Struct({ | ||
| 17 | login: Schema.String, | ||
| 18 | full_name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 19 | avatar_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 20 | }); | ||
| 21 | export const ForgejoLabel = Schema.Struct({ | ||
| 22 | id: Schema.Int, | ||
| 23 | name: Schema.String, | ||
| 24 | color: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 25 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 26 | }); | ||
| 27 | export const ForgejoRepository = Schema.Struct({ | ||
| 28 | full_name: Schema.String, | ||
| 29 | permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })), | ||
| 30 | archived: Schema.optional(Schema.Boolean), | ||
| 31 | allow_merge_commits: Schema.optional(Schema.Boolean), | ||
| 32 | allow_squash_merge: Schema.optional(Schema.Boolean), | ||
| 33 | allow_rebase: Schema.optional(Schema.Boolean), | ||
| 34 | allow_rebase_update: Schema.optional(Schema.Boolean), | ||
| 35 | }); | ||
| 36 | const Branch = Schema.Struct({ | ||
| 37 | ref: Schema.String, | ||
| 38 | sha: Schema.String, | ||
| 39 | repo: Schema.NullOr(ForgejoRepository), | ||
| 40 | }); | ||
| 41 | export const ForgejoPullRequest = Schema.Struct({ | ||
| 42 | number: Schema.Int, | ||
| 43 | title: Schema.String, | ||
| 44 | body: Schema.NullOr(Schema.String), | ||
| 45 | html_url: Schema.String, | ||
| 46 | user: Schema.NullOr(ForgejoUser), | ||
| 47 | state: Schema.String, | ||
| 48 | draft: Schema.optional(Schema.Boolean), | ||
| 49 | merged: Schema.Boolean, | ||
| 50 | mergeable: Schema.optional(Schema.Boolean), | ||
| 51 | is_locked: Schema.optional(Schema.Boolean), | ||
| 52 | head: Branch, | ||
| 53 | base: Branch, | ||
| 54 | merge_base: Schema.optional(Schema.String), | ||
| 55 | created_at: Schema.String, | ||
| 56 | updated_at: Schema.String, | ||
| 57 | closed_at: Schema.NullOr(Schema.String), | ||
| 58 | merged_at: Schema.NullOr(Schema.String), | ||
| 59 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 60 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 61 | changed_files: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 62 | comments: Schema.optional(Schema.Int), | ||
| 63 | labels: Schema.NullOr(Schema.Array(ForgejoLabel)), | ||
| 64 | requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), | ||
| 65 | }); | ||
| 66 | export const ForgejoComment = Schema.Struct({ | ||
| 67 | id: Schema.Int, | ||
| 68 | body: Schema.String, | ||
| 69 | user: Schema.NullOr(ForgejoUser), | ||
| 70 | created_at: Schema.String, | ||
| 71 | html_url: Schema.optional(Schema.String), | ||
| 72 | }); | ||
| 73 | export const ForgejoReview = Schema.Struct({ | ||
| 74 | id: Schema.Int, | ||
| 75 | body: Schema.String, | ||
| 76 | user: Schema.NullOr(ForgejoUser), | ||
| 77 | state: Schema.String, | ||
| 78 | submitted_at: Schema.String, | ||
| 79 | html_url: Schema.optional(Schema.String), | ||
| 80 | comments_count: Schema.Int, | ||
| 81 | }); | ||
| 82 | export const ForgejoReviewComment = Schema.Struct({ | ||
| 83 | ...ForgejoComment.fields, | ||
| 84 | path: Schema.String, | ||
| 85 | position: Schema.Int, | ||
| 86 | original_position: Schema.Int, | ||
| 87 | commit_id: Schema.String, | ||
| 88 | original_commit_id: Schema.String, | ||
| 89 | resolver: Schema.NullOr(ForgejoUser), | ||
| 90 | }); | ||
| 91 | export const ForgejoCommit = Schema.Struct({ | ||
| 92 | sha: Schema.String, | ||
| 93 | author: Schema.NullOr(ForgejoUser), | ||
| 94 | commit: Schema.Struct({ | ||
| 95 | message: Schema.String, | ||
| 96 | committer: Schema.Struct({ date: Schema.String }), | ||
| 97 | }), | ||
| 98 | parents: Schema.Array(Schema.Struct({ sha: Schema.String })), | ||
| 99 | stats: Schema.optional( | ||
| 100 | Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })), | ||
| 101 | ), | ||
| 102 | }); | ||
| 103 | export const ForgejoStatus = Schema.Struct({ | ||
| 104 | context: Schema.String, | ||
| 105 | status: Schema.String, | ||
| 106 | description: Schema.NullOr(Schema.String), | ||
| 107 | target_url: Schema.NullOr(Schema.String), | ||
| 108 | updated_at: Schema.String, | ||
| 109 | }); | ||
| 110 | export const ForgejoReaction = Schema.Struct({ | ||
| 111 | content: Schema.String, | ||
| 112 | user: Schema.NullOr(ForgejoUser), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export function forgejoActor( | ||
| 116 | user: typeof ForgejoUser.Type | null | undefined, | ||
| 117 | ): PullRequestActor | null { | ||
| 118 | return user?.login | ||
| 119 | ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null } | ||
| 120 | : null; | ||
| 121 | } | ||
| 122 | |||
| 123 | function toIsoUtc(value: string): string { | ||
| 124 | return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso }); | ||
| 125 | } | ||
| 126 | |||
| 127 | export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) { | ||
| 128 | return { | ||
| 129 | number: pr.number, | ||
| 130 | title: pr.title, | ||
| 131 | url: pr.html_url, | ||
| 132 | author: forgejoActor(pr.user), | ||
| 133 | headBranch: pr.head.ref, | ||
| 134 | baseBranch: pr.base.ref, | ||
| 135 | headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null, | ||
| 136 | state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open", | ||
| 137 | isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title), | ||
| 138 | // A false value also covers drafts and unfinished or failed conflict checks. | ||
| 139 | mergeability: pr.mergeable === true ? "mergeable" : "unknown", | ||
| 140 | additions: pr.additions ?? 0, | ||
| 141 | deletions: pr.deletions ?? 0, | ||
| 142 | createdAt: toIsoUtc(pr.created_at), | ||
| 143 | updatedAt: toIsoUtc(pr.updated_at), | ||
| 144 | closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at), | ||
| 145 | mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at), | ||
| 146 | reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login), | ||
| 147 | labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })), | ||
| 148 | } satisfies ProviderChangeRequest; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment { | ||
| 152 | return { | ||
| 153 | id: String(comment.id), | ||
| 154 | kind: "issue-comment", | ||
| 155 | author: forgejoActor(comment.user), | ||
| 156 | body: comment.body, | ||
| 157 | createdAt: toIsoUtc(comment.created_at), | ||
| 158 | url: comment.html_url || null, | ||
| 159 | path: null, | ||
| 160 | reviewState: null, | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment { | ||
| 165 | return { | ||
| 166 | id: `review:${review.id}`, | ||
| 167 | kind: "review", | ||
| 168 | author: forgejoActor(review.user), | ||
| 169 | body: review.body, | ||
| 170 | createdAt: toIsoUtc(review.submitted_at), | ||
| 171 | url: review.html_url || null, | ||
| 172 | path: null, | ||
| 173 | reviewState: | ||
| 174 | review.state === "REQUEST_CHANGES" | ||
| 175 | ? "CHANGES_REQUESTED" | ||
| 176 | : review.state === "COMMENT" | ||
| 177 | ? "COMMENTED" | ||
| 178 | : review.state, | ||
| 179 | }; | ||
| 180 | } | ||
| 181 | |||
| 182 | export function forgejoReviewThread( | ||
| 183 | comment: typeof ForgejoReviewComment.Type, | ||
| 184 | ): PullRequestReviewThread { | ||
| 185 | const oldSide = comment.position === 0 && comment.original_position > 0; | ||
| 186 | const line = oldSide ? comment.original_position : comment.position; | ||
| 187 | return { | ||
| 188 | id: String(comment.id), | ||
| 189 | path: comment.path, | ||
| 190 | line: line > 0 ? line : null, | ||
| 191 | side: oldSide ? "left" : "right", | ||
| 192 | isResolved: comment.resolver !== null, | ||
| 193 | isOutdated: false, | ||
| 194 | comments: [forgejoComment(comment)], | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | |||
| 198 | export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit { | ||
| 199 | const author = forgejoActor(commit.author); | ||
| 200 | return { | ||
| 201 | oid: commit.sha, | ||
| 202 | messageHeadline: commit.commit.message.split("\n")[0] ?? "", | ||
| 203 | committedDate: toIsoUtc(commit.commit.committer.date), | ||
| 204 | authors: author ? [author] : [], | ||
| 205 | ...(commit.stats | ||
| 206 | ? { additions: commit.stats.additions, deletions: commit.stats.deletions } | ||
| 207 | : {}), | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | |||
| 211 | export function forgejoChecks( | ||
| 212 | statuses: ReadonlyArray<typeof ForgejoStatus.Type>, | ||
| 213 | ): ReadonlyArray<PullRequestCheck> { | ||
| 214 | return dedupeChecks( | ||
| 215 | statuses.map((status) => ({ | ||
| 216 | workflowName: null, | ||
| 217 | at: status.updated_at, | ||
| 218 | check: { | ||
| 219 | name: status.context || "check", | ||
| 220 | description: status.description || null, | ||
| 221 | url: status.target_url || null, | ||
| 222 | status: | ||
| 223 | status.status === "success" | ||
| 224 | ? "success" | ||
| 225 | : status.status === "failure" || status.status === "error" | ||
| 226 | ? "failure" | ||
| 227 | : "pending", | ||
| 228 | }, | ||
| 229 | })), | ||
| 230 | ); | ||
| 231 | } | ||
| 232 | |||
| 233 | export const FORGEJO_REACTIONS: Record<PullRequestReactionContent, string> = { | ||
| 234 | "thumbs-up": "+1", | ||
| 235 | "thumbs-down": "-1", | ||
| 236 | laugh: "laugh", | ||
| 237 | hooray: "hooray", | ||
| 238 | confused: "confused", | ||
| 239 | heart: "heart", | ||
| 240 | rocket: "rocket", | ||
| 241 | eyes: "eyes", | ||
| 242 | }; | ||
| 243 | export function forgejoReactions( | ||
| 244 | reactions: ReadonlyArray<typeof ForgejoReaction.Type>, | ||
| 245 | viewer: string, | ||
| 246 | ): ReadonlyArray<PullRequestReaction> { | ||
| 247 | return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => { | ||
| 248 | const matching = reactions.filter((reaction) => reaction.content === emoji); | ||
| 249 | return matching.length === 0 | ||
| 250 | ? [] | ||
| 251 | : [ | ||
| 252 | { | ||
| 253 | content: content as PullRequestReactionContent, | ||
| 254 | count: matching.length, | ||
| 255 | actors: matching.flatMap((reaction) => | ||
| 256 | reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [], | ||
| 257 | ), | ||
| 258 | viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer), | ||
| 259 | }, | ||
| 260 | ]; | ||
| 261 | }); | ||
| 262 | } | ||
apps/server/src/server.ts+3-4| ... | @@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | ... | @@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 56 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 56 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 57 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | 57 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 58 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 58 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 59 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; | 59 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 60 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; | ||
| 60 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 61 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 61 | import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; | 62 | import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; |
| 62 | import * as TerminalManager from "./terminal/Manager.ts"; | 63 | import * as TerminalManager from "./terminal/Manager.ts"; |
| ... | @@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | ... | @@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; |
| 104 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 105 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 105 | import * as ReviewService from "./review/ReviewService.ts"; | 106 | import * as ReviewService from "./review/ReviewService.ts"; |
| 106 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 107 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 107 | import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; | ||
| 108 | import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; | 108 | import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; |
| 109 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | 109 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 110 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 110 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| ... | @@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p | ... | @@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p |
| 305 | BitbucketApi.layer, | 305 | BitbucketApi.layer, |
| 306 | GitHubApi.layerWithDependencies, | 306 | GitHubApi.layerWithDependencies, |
| 307 | GitLabCli.layer, | 307 | GitLabCli.layer, |
| 308 | ForgejoCli.layer, | 308 | SourceControlBuiltInDrivers.layer, |
| 309 | ServerSourceControlHost.layer, | ||
| 310 | ), | 309 | ), |
| 311 | ), | 310 | ), |
| 312 | Layer.provideMerge(GitVcsDriver.layer), | 311 | Layer.provideMerge(GitVcsDriver.layer), |
apps/server/src/sourceControl/ForgejoCli.ts deleted-724| ... | @@ -1,724 +0,0 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Semaphore from "effect/Semaphore"; | ||
| 9 | import * as NodeOS from "node:os"; | ||
| 10 | // @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. | ||
| 11 | import * as NodePath from "node:path"; | ||
| 12 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 13 | import { ChildProcessSpawner } from "effect/process"; | ||
| 14 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 17 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | ||
| 18 | import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 19 | |||
| 20 | const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 21 | |||
| 22 | export class ForgejoCliError extends Schema.TaggedError<ForgejoCliError>()("ForgejoCliError", { | ||
| 23 | command: Schema.Literals(["fj", "tea"]), | ||
| 24 | cwd: Schema.String, | ||
| 25 | detail: Schema.String, | ||
| 26 | reason: Schema.optional( | ||
| 27 | Schema.Literals([ | ||
| 28 | "missing-cli", | ||
| 29 | "authentication", | ||
| 30 | "forbidden", | ||
| 31 | "not-found", | ||
| 32 | "rate-limit", | ||
| 33 | "invalid-response", | ||
| 34 | ]), | ||
| 35 | ), | ||
| 36 | httpStatus: Schema.optional(Schema.Int), | ||
| 37 | cause: Schema.optional(Schema.Defect()), | ||
| 38 | }) {} | ||
| 39 | |||
| 40 | export const ForgejoLoginSchema = Schema.Struct({ | ||
| 41 | name: Schema.String, | ||
| 42 | url: Schema.String, | ||
| 43 | ssh_host: Schema.optional(Schema.String), | ||
| 44 | valid: Schema.optional(Schema.String), | ||
| 45 | user: Schema.String, | ||
| 46 | default: Schema.String, | ||
| 47 | }); | ||
| 48 | |||
| 49 | export function parseForgejoLogins(raw: string) { | ||
| 50 | const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw); | ||
| 51 | return Result.isSuccess(decoded) ? decoded.success : []; | ||
| 52 | } | ||
| 53 | |||
| 54 | export const ForgejoKeysSchema = Schema.Struct({ | ||
| 55 | hosts: Schema.Record( | ||
| 56 | Schema.String, | ||
| 57 | Schema.Struct({ | ||
| 58 | type: Schema.Literals(["Application", "OAuth"]), | ||
| 59 | token: Schema.String, | ||
| 60 | }), | ||
| 61 | ), | ||
| 62 | aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema); | ||
| 66 | |||
| 67 | /** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */ | ||
| 68 | function forgejoKeysPaths(input: { | ||
| 69 | readonly platform: string; | ||
| 70 | readonly home: string; | ||
| 71 | readonly dataHome?: string; | ||
| 72 | readonly appData?: string; | ||
| 73 | }) { | ||
| 74 | if (input.platform === "darwin") | ||
| 75 | return ["forgejo-cli", "Cyborus"].map((organization) => | ||
| 76 | NodePath.join( | ||
| 77 | input.home, | ||
| 78 | "Library", | ||
| 79 | "Application Support", | ||
| 80 | `${organization}.forgejo-cli`, | ||
| 81 | "keys.json", | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | if (input.platform === "win32") | ||
| 85 | return ["forgejo-cli", "Cyborus"].map((organization) => | ||
| 86 | NodePath.win32.join( | ||
| 87 | input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"), | ||
| 88 | organization, | ||
| 89 | "forgejo-cli", | ||
| 90 | "data", | ||
| 91 | "keys.json", | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | return [ | ||
| 95 | NodePath.join( | ||
| 96 | input.dataHome && NodePath.isAbsolute(input.dataHome) | ||
| 97 | ? input.dataHome | ||
| 98 | : NodePath.join(input.home, ".local", "share"), | ||
| 99 | "forgejo-cli", | ||
| 100 | "keys.json", | ||
| 101 | ), | ||
| 102 | ]; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface ForgejoRepositoryInput { | ||
| 106 | readonly cwd: string; | ||
| 107 | readonly context?: SourceControlProviderContext; | ||
| 108 | readonly repository?: string; | ||
| 109 | readonly reference?: string; | ||
| 110 | readonly host?: string; | ||
| 111 | } | ||
| 112 | |||
| 113 | export interface ForgejoRepository { | ||
| 114 | readonly command?: "fj" | "tea"; | ||
| 115 | readonly login: string; | ||
| 116 | readonly repository: string; | ||
| 117 | readonly baseUrl: string; | ||
| 118 | } | ||
| 119 | |||
| 120 | export interface ForgejoApiInput extends ForgejoRepositoryInput { | ||
| 121 | readonly path: string; | ||
| 122 | readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | ||
| 123 | readonly body?: unknown; | ||
| 124 | } | ||
| 125 | |||
| 126 | export class ForgejoCli extends Context.Service< | ||
| 127 | ForgejoCli, | ||
| 128 | { | ||
| 129 | readonly execute: (input: { | ||
| 130 | readonly command?: "fj" | "tea"; | ||
| 131 | readonly cwd: string; | ||
| 132 | readonly args: ReadonlyArray<string>; | ||
| 133 | readonly stdin?: string; | ||
| 134 | readonly timeoutMs?: number; | ||
| 135 | readonly maxOutputBytes?: number; | ||
| 136 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, ForgejoCliError>; | ||
| 137 | readonly listLogins?: (input: { | ||
| 138 | readonly cwd: string; | ||
| 139 | readonly command: "fj" | "tea"; | ||
| 140 | readonly remoteUrl?: string; | ||
| 141 | }) => Effect.Effect<ReturnType<typeof parseForgejoLogins>, ForgejoCliError>; | ||
| 142 | readonly getAccount?: (input: { | ||
| 143 | readonly cwd: string; | ||
| 144 | readonly baseUrl: string; | ||
| 145 | }) => Effect.Effect<string, ForgejoCliError>; | ||
| 146 | readonly resolveRepository: ( | ||
| 147 | input: ForgejoRepositoryInput, | ||
| 148 | ) => Effect.Effect<ForgejoRepository, ForgejoCliError>; | ||
| 149 | readonly api: ( | ||
| 150 | input: ForgejoApiInput, | ||
| 151 | ) => Effect.Effect<VcsProcess.VcsProcessOutput, ForgejoCliError>; | ||
| 152 | } | ||
| 153 | >()("t3/sourceControl/ForgejoCli") {} | ||
| 154 | |||
| 155 | export function parseForgejoRemote(value: string) { | ||
| 156 | if (/^(?:https?|ssh):\/\//i.test(value)) { | ||
| 157 | try { | ||
| 158 | const url = new URL(value); | ||
| 159 | return { | ||
| 160 | host: url.host.toLowerCase(), | ||
| 161 | hostname: url.hostname.toLowerCase(), | ||
| 162 | ssh: url.protocol === "ssh:", | ||
| 163 | path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""), | ||
| 164 | }; | ||
| 165 | } catch { | ||
| 166 | return null; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | // SCP remotes may omit the username; URL treats these as a custom scheme. | ||
| 170 | const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value); | ||
| 171 | return ssh?.[1] && ssh[2] | ||
| 172 | ? { | ||
| 173 | host: ssh[1].toLowerCase(), | ||
| 174 | hostname: ssh[1].toLowerCase(), | ||
| 175 | ssh: true, | ||
| 176 | path: ssh[2].replace(/\.git$/, ""), | ||
| 177 | } | ||
| 178 | : null; | ||
| 179 | } | ||
| 180 | |||
| 181 | export function matchForgejoLogin( | ||
| 182 | logins: ReturnType<typeof parseForgejoLogins>, | ||
| 183 | remote: NonNullable<ReturnType<typeof parseForgejoRemote>>, | ||
| 184 | requestedHost?: string, | ||
| 185 | hostOnly = false, | ||
| 186 | ) { | ||
| 187 | const matches = [ | ||
| 188 | ...new Map( | ||
| 189 | logins | ||
| 190 | .filter((login) => { | ||
| 191 | const url = parseForgejoRemote(login.url); | ||
| 192 | if (!url) return false; | ||
| 193 | if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false; | ||
| 194 | return remote.ssh | ||
| 195 | ? login.ssh_host?.toLowerCase() === remote.host || | ||
| 196 | login.ssh_host?.toLowerCase() === remote.hostname || | ||
| 197 | url.hostname === remote.hostname | ||
| 198 | : url.host === remote.host && | ||
| 199 | ((hostOnly && !remote.path) || | ||
| 200 | !url.path || | ||
| 201 | remote.path === url.path || | ||
| 202 | remote.path.startsWith(`${url.path}/`)); | ||
| 203 | }) | ||
| 204 | .map((login) => [login.name, login]), | ||
| 205 | ).values(), | ||
| 206 | ]; | ||
| 207 | return matches.length === 1 | ||
| 208 | ? matches[0] | ||
| 209 | : new Set(matches.map((login) => login.url)).size === 1 | ||
| 210 | ? matches.find((login) => login.default === "true") | ||
| 211 | : undefined; | ||
| 212 | } | ||
| 213 | |||
| 214 | export const make = Effect.gen(function* () { | ||
| 215 | const process = yield* VcsProcess.VcsProcess; | ||
| 216 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 217 | const httpClient = yield* HttpClient.HttpClient; | ||
| 218 | const authLock = yield* Semaphore.make(1); | ||
| 219 | const authenticated = new Map<string, { token: string; time: number }>(); | ||
| 220 | const execute: ForgejoCli["Service"]["execute"] = (input) => | ||
| 221 | process | ||
| 222 | .run({ | ||
| 223 | ...input, | ||
| 224 | operation: "ForgejoCli.execute", | ||
| 225 | command: input.command ?? "tea", | ||
| 226 | timeoutMs: input.timeoutMs ?? 30_000, | ||
| 227 | }) | ||
| 228 | .pipe( | ||
| 229 | Effect.mapError( | ||
| 230 | (cause) => | ||
| 231 | new ForgejoCliError({ | ||
| 232 | command: input.command ?? "tea", | ||
| 233 | cwd: input.cwd, | ||
| 234 | ...(input.command === "fj" ? {} : { cause }), | ||
| 235 | ...(cause._tag === "VcsProcessSpawnError" | ||
| 236 | ? { reason: "missing-cli" as const } | ||
| 237 | : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" | ||
| 238 | ? { reason: "authentication" as const } | ||
| 239 | : {}), | ||
| 240 | detail: | ||
| 241 | cause._tag === "VcsProcessSpawnError" | ||
| 242 | ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry." | ||
| 243 | : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" | ||
| 244 | ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`." | ||
| 245 | : "Forgejo CLI command failed.", | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | ); | ||
| 249 | |||
| 250 | const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { | ||
| 251 | for (const path of forgejoKeysPaths({ | ||
| 252 | platform: yield* HostProcessPlatform, | ||
| 253 | home: NodeOS.homedir(), | ||
| 254 | ...(globalThis.process.env.XDG_DATA_HOME | ||
| 255 | ? { dataHome: globalThis.process.env.XDG_DATA_HOME } | ||
| 256 | : {}), | ||
| 257 | ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}), | ||
| 258 | })) { | ||
| 259 | const exists = yield* fileSystem.exists(path).pipe( | ||
| 260 | Effect.mapError( | ||
| 261 | () => | ||
| 262 | new ForgejoCliError({ | ||
| 263 | command: "fj", | ||
| 264 | cwd, | ||
| 265 | reason: "authentication", | ||
| 266 | detail: "Could not read fj authentication storage.", | ||
| 267 | }), | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | if (!exists) continue; | ||
| 271 | const raw = yield* fileSystem.readFileString(path).pipe( | ||
| 272 | Effect.mapError( | ||
| 273 | () => | ||
| 274 | new ForgejoCliError({ | ||
| 275 | command: "fj", | ||
| 276 | cwd, | ||
| 277 | reason: "authentication", | ||
| 278 | detail: "Could not read fj authentication storage.", | ||
| 279 | }), | ||
| 280 | ), | ||
| 281 | ); | ||
| 282 | const decoded = parseForgejoKeys(raw); | ||
| 283 | if (Result.isFailure(decoded)) | ||
| 284 | return yield* new ForgejoCliError({ | ||
| 285 | command: "fj", | ||
| 286 | cwd, | ||
| 287 | reason: "authentication", | ||
| 288 | detail: "fj authentication storage is invalid. Authenticate again with fj.", | ||
| 289 | }); | ||
| 290 | return decoded.success; | ||
| 291 | } | ||
| 292 | const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} }; | ||
| 293 | return empty; | ||
| 294 | }); | ||
| 295 | |||
| 296 | const publicLogins = ( | ||
| 297 | keys: typeof ForgejoKeysSchema.Type, | ||
| 298 | remoteUrl?: string, | ||
| 299 | ): ReturnType<typeof parseForgejoLogins> => { | ||
| 300 | const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; | ||
| 301 | return Object.keys(keys.hosts).flatMap((host) => { | ||
| 302 | const url = parseForgejoRemote(`https://${host}`); | ||
| 303 | // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them. | ||
| 304 | if (!url || url.path) return []; | ||
| 305 | // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP. | ||
| 306 | const scheme = | ||
| 307 | remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "") | ||
| 308 | ? "http" | ||
| 309 | : "https"; | ||
| 310 | const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" }; | ||
| 311 | const aliases = Object.entries(keys.aliases ?? {}) | ||
| 312 | .filter(([, target]) => target === host) | ||
| 313 | .map(([alias]) => ({ ...login, ssh_host: alias })); | ||
| 314 | return aliases.length ? aliases : [login]; | ||
| 315 | }); | ||
| 316 | }; | ||
| 317 | |||
| 318 | const listLogins: NonNullable<ForgejoCli["Service"]["listLogins"]> = Effect.fn( | ||
| 319 | "ForgejoCli.listLogins", | ||
| 320 | )(function* (input) { | ||
| 321 | if (input.command === "fj") { | ||
| 322 | const keys = yield* readKeys(input.cwd).pipe(Effect.result); | ||
| 323 | if (Result.isFailure(keys)) { | ||
| 324 | // Stale credentials from an uninstalled fj must not disable an available tea login. | ||
| 325 | const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( | ||
| 326 | Effect.result, | ||
| 327 | ); | ||
| 328 | if (Result.isFailure(available) && available.failure.reason === "missing-cli") return []; | ||
| 329 | return yield* keys.failure; | ||
| 330 | } | ||
| 331 | return publicLogins(keys.success, input.remoteUrl); | ||
| 332 | } | ||
| 333 | return parseForgejoLogins( | ||
| 334 | (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout, | ||
| 335 | ); | ||
| 336 | }); | ||
| 337 | |||
| 338 | const requestFj = Effect.fn("ForgejoCli.requestFj")( | ||
| 339 | function* (input: { | ||
| 340 | readonly cwd: string; | ||
| 341 | readonly baseUrl: string; | ||
| 342 | readonly token: string; | ||
| 343 | readonly path: string; | ||
| 344 | readonly method?: ForgejoApiInput["method"]; | ||
| 345 | readonly body?: string; | ||
| 346 | }) { | ||
| 347 | const base = new URL(`${input.baseUrl}/api/v1/`); | ||
| 348 | const url = new URL(input.path, base); | ||
| 349 | if ( | ||
| 350 | url.origin !== base.origin || | ||
| 351 | !url.pathname.startsWith(base.pathname) || | ||
| 352 | url.username || | ||
| 353 | url.password | ||
| 354 | ) | ||
| 355 | return yield* new ForgejoCliError({ | ||
| 356 | command: "fj", | ||
| 357 | cwd: input.cwd, | ||
| 358 | detail: "Invalid Forgejo API path.", | ||
| 359 | }); | ||
| 360 | let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe( | ||
| 361 | HttpClientRequest.setHeader("Authorization", `token ${input.token}`), | ||
| 362 | ); | ||
| 363 | if (input.body !== undefined) | ||
| 364 | request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); | ||
| 365 | const response = yield* httpClient | ||
| 366 | .execute(request) | ||
| 367 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); | ||
| 368 | const status = response.status; | ||
| 369 | const body = | ||
| 370 | status === 204 || status === 205 | ||
| 371 | ? { text: "", truncated: false, invalidUtf8: false } | ||
| 372 | : yield* collectUint8StreamText({ | ||
| 373 | stream: response.stream, | ||
| 374 | maxBytes: 8 * 1024 * 1024, | ||
| 375 | }); | ||
| 376 | if (body.truncated || body.invalidUtf8) | ||
| 377 | return yield* new ForgejoCliError({ | ||
| 378 | command: "fj", | ||
| 379 | cwd: input.cwd, | ||
| 380 | reason: "invalid-response", | ||
| 381 | detail: "Forgejo returned an oversized or invalid response.", | ||
| 382 | }); | ||
| 383 | if (status < 200 || status >= 300) { | ||
| 384 | const detail = | ||
| 385 | status === 404 | ||
| 386 | ? "Forgejo repository or pull request was not found." | ||
| 387 | : body.text | ||
| 388 | ? `Forgejo API request failed (HTTP ${status}): ${body.text}` | ||
| 389 | : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`; | ||
| 390 | return yield* new ForgejoCliError({ | ||
| 391 | command: "fj", | ||
| 392 | cwd: input.cwd, | ||
| 393 | httpStatus: status, | ||
| 394 | ...(status === 401 | ||
| 395 | ? { reason: "authentication" as const } | ||
| 396 | : status === 403 | ||
| 397 | ? { reason: "forbidden" as const } | ||
| 398 | : status === 404 | ||
| 399 | ? { reason: "not-found" as const } | ||
| 400 | : status === 429 | ||
| 401 | ? { reason: "rate-limit" as const } | ||
| 402 | : {}), | ||
| 403 | detail, | ||
| 404 | }); | ||
| 405 | } | ||
| 406 | return { | ||
| 407 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 408 | stdout: body.text, | ||
| 409 | stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`, | ||
| 410 | stdoutTruncated: false, | ||
| 411 | stderrTruncated: false, | ||
| 412 | }; | ||
| 413 | }, | ||
| 414 | (effect, input) => | ||
| 415 | effect.pipe( | ||
| 416 | Effect.timeout(30_000), | ||
| 417 | Effect.mapError((error) => | ||
| 418 | error._tag === "ForgejoCliError" | ||
| 419 | ? error | ||
| 420 | : new ForgejoCliError({ | ||
| 421 | command: "fj", | ||
| 422 | cwd: input.cwd, | ||
| 423 | detail: "Forgejo API request failed or timed out.", | ||
| 424 | }), | ||
| 425 | ), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | |||
| 429 | const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* ( | ||
| 430 | cwd: string, | ||
| 431 | login: typeof ForgejoLoginSchema.Type, | ||
| 432 | ) { | ||
| 433 | const keys = yield* readKeys(cwd); | ||
| 434 | const token = keys.hosts[login.name]?.token; | ||
| 435 | const now = yield* Clock.currentTimeMillis; | ||
| 436 | const cached = authenticated.get(login.url); | ||
| 437 | if (token && cached?.token === token && now - cached.time < 30_000) return token; | ||
| 438 | yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] }); | ||
| 439 | // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token. | ||
| 440 | const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token; | ||
| 441 | if (!refreshed) | ||
| 442 | return yield* new ForgejoCliError({ | ||
| 443 | command: "fj", | ||
| 444 | cwd, | ||
| 445 | reason: "authentication", | ||
| 446 | detail: "fj has no credentials for this server. Authenticate again with fj.", | ||
| 447 | }); | ||
| 448 | authenticated.set(login.url, { token: refreshed, time: now }); | ||
| 449 | return refreshed; | ||
| 450 | }, authLock.withPermits(1)); | ||
| 451 | |||
| 452 | const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn( | ||
| 453 | "ForgejoCli.getAccount", | ||
| 454 | )(function* (input) { | ||
| 455 | const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl }); | ||
| 456 | const login = logins.find( | ||
| 457 | (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""), | ||
| 458 | ); | ||
| 459 | if (!login) | ||
| 460 | return yield* new ForgejoCliError({ | ||
| 461 | command: "fj", | ||
| 462 | cwd: input.cwd, | ||
| 463 | reason: "authentication", | ||
| 464 | detail: "fj has no credentials for this server.", | ||
| 465 | }); | ||
| 466 | const token = yield* authenticateFj(input.cwd, login); | ||
| 467 | const currentUser = yield* requestFj({ | ||
| 468 | cwd: input.cwd, | ||
| 469 | baseUrl: login.url.replace(/\/+$/, ""), | ||
| 470 | token, | ||
| 471 | path: "user", | ||
| 472 | }); | ||
| 473 | const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout); | ||
| 474 | if (Result.isFailure(user) || !user.success.login.trim()) | ||
| 475 | return yield* new ForgejoCliError({ | ||
| 476 | command: "fj", | ||
| 477 | cwd: input.cwd, | ||
| 478 | reason: "invalid-response", | ||
| 479 | detail: "Forgejo returned an invalid account response.", | ||
| 480 | }); | ||
| 481 | return user.success.login; | ||
| 482 | }); | ||
| 483 | |||
| 484 | const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* ( | ||
| 485 | input: ForgejoRepositoryInput, | ||
| 486 | hostOnly = false, | ||
| 487 | ) { | ||
| 488 | const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null; | ||
| 489 | let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find( | ||
| 490 | (value) => value && parseForgejoRemote(value), | ||
| 491 | ); | ||
| 492 | let remote = | ||
| 493 | referenceRemote ?? | ||
| 494 | (input.repository ? parseForgejoRemote(input.repository) : null) ?? | ||
| 495 | (input.context ? parseForgejoRemote(input.context.remoteUrl) : null); | ||
| 496 | if (!remote && (!input.repository || input.host)) { | ||
| 497 | const result = yield* process | ||
| 498 | .run({ | ||
| 499 | operation: "ForgejoCli.remote", | ||
| 500 | command: "git", | ||
| 501 | args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"], | ||
| 502 | cwd: input.cwd, | ||
| 503 | allowNonZeroExit: true, | ||
| 504 | }) | ||
| 505 | .pipe( | ||
| 506 | Effect.mapError( | ||
| 507 | (cause) => | ||
| 508 | new ForgejoCliError({ | ||
| 509 | command: "tea", | ||
| 510 | cwd: input.cwd, | ||
| 511 | detail: "Could not resolve the Forgejo repository remote.", | ||
| 512 | cause, | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | ); | ||
| 516 | if (input.host) { | ||
| 517 | const matchingUrls = [ | ||
| 518 | ...new Set( | ||
| 519 | result.stdout.split("\n").flatMap((line) => { | ||
| 520 | const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1]; | ||
| 521 | return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase() | ||
| 522 | ? [url] | ||
| 523 | : []; | ||
| 524 | }), | ||
| 525 | ), | ||
| 526 | ]; | ||
| 527 | const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))]; | ||
| 528 | remoteUrl = | ||
| 529 | matchingUrls.length === 1 | ||
| 530 | ? matchingUrls[0] | ||
| 531 | : origins.length === 1 | ||
| 532 | ? origins[0] | ||
| 533 | : undefined; | ||
| 534 | } else { | ||
| 535 | remoteUrl = result.stdout.trim(); | ||
| 536 | } | ||
| 537 | remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; | ||
| 538 | } | ||
| 539 | if ( | ||
| 540 | input.host && | ||
| 541 | !remote?.ssh && | ||
| 542 | remote?.host !== input.host.toLowerCase() && | ||
| 543 | remote?.hostname !== input.host.toLowerCase() | ||
| 544 | ) | ||
| 545 | remote = { | ||
| 546 | host: input.host.toLowerCase(), | ||
| 547 | hostname: input.host.split(":")[0] ?? input.host, | ||
| 548 | ssh: false, | ||
| 549 | path: remote?.path ?? "", | ||
| 550 | }; | ||
| 551 | const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl; | ||
| 552 | const fjLogins = yield* listLogins({ | ||
| 553 | cwd: input.cwd, | ||
| 554 | command: "fj", | ||
| 555 | ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}), | ||
| 556 | }); | ||
| 557 | const requestedHost = input.host ?? input.context?.requestedHost; | ||
| 558 | const matchHostOnly = hostOnly || (!!input.host && !remote?.path); | ||
| 559 | const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) => | ||
| 560 | remote | ||
| 561 | ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly) | ||
| 562 | : (logins.find((item) => item.default === "true") ?? | ||
| 563 | (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined)); | ||
| 564 | let login = selectLogin(fjLogins); | ||
| 565 | let command: "fj" | "tea" = "fj"; | ||
| 566 | if ( | ||
| 567 | !login && | ||
| 568 | fjLogins.some( | ||
| 569 | (item) => | ||
| 570 | !remote || | ||
| 571 | matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly), | ||
| 572 | ) | ||
| 573 | ) { | ||
| 574 | const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( | ||
| 575 | Effect.result, | ||
| 576 | ); | ||
| 577 | if (Result.isSuccess(available)) | ||
| 578 | return yield* new ForgejoCliError({ | ||
| 579 | command: "fj", | ||
| 580 | cwd: input.cwd, | ||
| 581 | reason: "authentication", | ||
| 582 | detail: "Multiple fj logins match this repository. Specify its full server URL.", | ||
| 583 | }); | ||
| 584 | if (available.failure.reason !== "missing-cli") return yield* available.failure; | ||
| 585 | } | ||
| 586 | if (login) { | ||
| 587 | const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result); | ||
| 588 | if (Result.isFailure(auth)) { | ||
| 589 | if (auth.failure.reason === "missing-cli") login = undefined; | ||
| 590 | else return yield* auth.failure; | ||
| 591 | } | ||
| 592 | } | ||
| 593 | if (!login) { | ||
| 594 | command = "tea"; | ||
| 595 | login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" })); | ||
| 596 | } | ||
| 597 | if (!login) | ||
| 598 | return yield* new ForgejoCliError({ | ||
| 599 | command: "tea", | ||
| 600 | cwd: input.cwd, | ||
| 601 | reason: "authentication", | ||
| 602 | detail: | ||
| 603 | "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.", | ||
| 604 | }); | ||
| 605 | if (hostOnly) | ||
| 606 | return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") }; | ||
| 607 | const path = | ||
| 608 | referenceRemote?.path ?? | ||
| 609 | (input.repository && !parseForgejoRemote(input.repository) | ||
| 610 | ? input.repository | ||
| 611 | : remote?.path) ?? | ||
| 612 | ""; | ||
| 613 | const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, ""); | ||
| 614 | const relativePath = | ||
| 615 | basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`) | ||
| 616 | ? path.slice(basePath.length + 1) | ||
| 617 | : path; | ||
| 618 | const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, ""); | ||
| 619 | if (command === "fj" && !repositoryPath.includes("/")) { | ||
| 620 | login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) }; | ||
| 621 | } | ||
| 622 | const repository = repositoryPath.includes("/") | ||
| 623 | ? repositoryPath | ||
| 624 | : `${login.user}/${repositoryPath}`; | ||
| 625 | if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) | ||
| 626 | return yield* new ForgejoCliError({ | ||
| 627 | command, | ||
| 628 | cwd: input.cwd, | ||
| 629 | detail: "Specify a Forgejo repository as owner/repository or its full server URL.", | ||
| 630 | }); | ||
| 631 | return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") }; | ||
| 632 | }); | ||
| 633 | const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input); | ||
| 634 | const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) { | ||
| 635 | const repository = yield* resolveTarget( | ||
| 636 | input, | ||
| 637 | input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"), | ||
| 638 | ); | ||
| 639 | const stdin = | ||
| 640 | input.body === undefined | ||
| 641 | ? undefined | ||
| 642 | : yield* encodeApiBody(input.body).pipe( | ||
| 643 | Effect.mapError( | ||
| 644 | (cause) => | ||
| 645 | new ForgejoCliError({ | ||
| 646 | command: "tea", | ||
| 647 | cwd: input.cwd, | ||
| 648 | detail: "Could not encode the Forgejo request body.", | ||
| 649 | cause, | ||
| 650 | }), | ||
| 651 | ), | ||
| 652 | ); | ||
| 653 | let path = input.path.replace(/^\/+/, ""); | ||
| 654 | if (input.repository && input.repository !== repository.repository) { | ||
| 655 | // Repository identities retain the server mount path; API routes do not. | ||
| 656 | const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 657 | if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) { | ||
| 658 | path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`; | ||
| 659 | } | ||
| 660 | } | ||
| 661 | if (repository.command === "fj") { | ||
| 662 | const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token; | ||
| 663 | if (!token) | ||
| 664 | return yield* new ForgejoCliError({ | ||
| 665 | command: "fj", | ||
| 666 | cwd: input.cwd, | ||
| 667 | reason: "authentication", | ||
| 668 | detail: "fj has no credentials for this server.", | ||
| 669 | }); | ||
| 670 | return yield* requestFj({ | ||
| 671 | cwd: input.cwd, | ||
| 672 | baseUrl: repository.baseUrl, | ||
| 673 | token, | ||
| 674 | path, | ||
| 675 | ...(input.method === undefined ? {} : { method: input.method }), | ||
| 676 | ...(stdin === undefined ? {} : { body: stdin }), | ||
| 677 | }); | ||
| 678 | } | ||
| 679 | const result = yield* execute({ | ||
| 680 | cwd: input.cwd, | ||
| 681 | args: [ | ||
| 682 | "api", | ||
| 683 | "--include", | ||
| 684 | "--login", | ||
| 685 | repository.login, | ||
| 686 | ...(repository.repository ? ["--repo", repository.repository] : []), | ||
| 687 | "--method", | ||
| 688 | input.method ?? "GET", | ||
| 689 | ...(input.body === undefined ? [] : ["--data", "@-"]), | ||
| 690 | `${repository.baseUrl}/api/v1/${path}`, | ||
| 691 | ], | ||
| 692 | ...(stdin === undefined ? {} : { stdin }), | ||
| 693 | }); | ||
| 694 | // tea reports HTTP failures with exit code zero; use its response status. | ||
| 695 | const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]); | ||
| 696 | if (!status || status >= 400) | ||
| 697 | return yield* new ForgejoCliError({ | ||
| 698 | command: "tea", | ||
| 699 | cwd: input.cwd, | ||
| 700 | ...(status ? { httpStatus: status } : {}), | ||
| 701 | ...(status === 401 | ||
| 702 | ? { reason: "authentication" as const } | ||
| 703 | : status === 403 | ||
| 704 | ? { reason: "forbidden" as const } | ||
| 705 | : status === 404 | ||
| 706 | ? { reason: "not-found" as const } | ||
| 707 | : status === 429 | ||
| 708 | ? { reason: "rate-limit" as const } | ||
| 709 | : {}), | ||
| 710 | detail: | ||
| 711 | status === 401 || status === 403 | ||
| 712 | ? "Forgejo denied access. Check this server's `tea login` credentials and permissions." | ||
| 713 | : status === 404 | ||
| 714 | ? "Forgejo repository or pull request was not found." | ||
| 715 | : status === 429 | ||
| 716 | ? "Forgejo API rate limit exceeded." | ||
| 717 | : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`, | ||
| 718 | }); | ||
| 719 | return result; | ||
| 720 | }); | ||
| 721 | return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api }); | ||
| 722 | }); | ||
| 723 | |||
| 724 | export const layer = Layer.effect(ForgejoCli, make); | ||
apps/server/src/sourceControl/ForgejoSourceControlProvider.ts deleted-387| ... | @@ -1,387 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { SourceControlProviderError } from "@t3tools/contracts"; | ||
| 6 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 7 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 8 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 9 | import { | ||
| 10 | providerAuth, | ||
| 11 | probeSourceControlProvider, | ||
| 12 | type SourceControlCliDiscoverySpec, | ||
| 13 | type SourceControlManagedCliDiscoverySpec, | ||
| 14 | } from "@t3tools/source-control-core/server/discovery"; | ||
| 15 | import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; | ||
| 16 | |||
| 17 | const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); | ||
| 18 | |||
| 19 | export const discovery = { | ||
| 20 | type: "cli", | ||
| 21 | kind: "forgejo", | ||
| 22 | label: "Forgejo / Gitea", | ||
| 23 | executable: "tea", | ||
| 24 | versionArgs: ["--version"], | ||
| 25 | authArgs: ["login", "status", "--output", "json"], | ||
| 26 | remoteRefinementArgs: ["login", "list", "--output", "json"], | ||
| 27 | parseAuth: (input) => { | ||
| 28 | const logins = ForgejoCli.parseForgejoLogins(input.stdout); | ||
| 29 | const login = logins.find((entry) => entry.default === "true") ?? logins[0]; | ||
| 30 | return login | ||
| 31 | ? providerAuth({ | ||
| 32 | status: login.valid === "true" ? "authenticated" : "unauthenticated", | ||
| 33 | account: login.user, | ||
| 34 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 35 | }) | ||
| 36 | : providerAuth({ | ||
| 37 | status: "unauthenticated", | ||
| 38 | detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.", | ||
| 39 | }); | ||
| 40 | }, | ||
| 41 | refineUnknownRemote: (input) => { | ||
| 42 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); | ||
| 43 | const login = | ||
| 44 | remote && | ||
| 45 | ForgejoCli.matchForgejoLogin( | ||
| 46 | ForgejoCli.parseForgejoLogins(input.auth.stdout), | ||
| 47 | remote, | ||
| 48 | input.context.requestedHost, | ||
| 49 | ); | ||
| 50 | return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null; | ||
| 51 | }, | ||
| 52 | installHint: | ||
| 53 | "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", | ||
| 54 | } satisfies SourceControlCliDiscoverySpec; | ||
| 55 | |||
| 56 | export const makeDiscovery = Effect.gen(function* () { | ||
| 57 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 58 | const process = yield* VcsProcess.VcsProcess; | ||
| 59 | const listLogins = cli.listLogins; | ||
| 60 | if (!listLogins) return discovery; | ||
| 61 | return { | ||
| 62 | type: "managed-cli", | ||
| 63 | kind: "forgejo", | ||
| 64 | label: discovery.label, | ||
| 65 | installHint: discovery.installHint, | ||
| 66 | probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { | ||
| 67 | const remoteUrl = yield* process | ||
| 68 | .run({ | ||
| 69 | operation: "source-control.discovery.remote", | ||
| 70 | command: "git", | ||
| 71 | args: ["remote", "get-url", "origin"], | ||
| 72 | cwd, | ||
| 73 | allowNonZeroExit: true, | ||
| 74 | timeoutMs: 5_000, | ||
| 75 | maxOutputBytes: 8_000, | ||
| 76 | }) | ||
| 77 | .pipe( | ||
| 78 | Effect.map((result) => result.stdout.trim()), | ||
| 79 | Effect.orElseSucceed(() => ""), | ||
| 80 | ); | ||
| 81 | const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl })); | ||
| 82 | const logins = Result.isSuccess(credentials) ? credentials.success : []; | ||
| 83 | const remote = ForgejoCli.parseForgejoRemote(remoteUrl); | ||
| 84 | const login = | ||
| 85 | (remote && ForgejoCli.matchForgejoLogin(logins, remote)) || | ||
| 86 | logins.find((entry) => entry.default === "true") || | ||
| 87 | logins[0]; | ||
| 88 | const fj = yield* probeSourceControlProvider({ | ||
| 89 | cwd, | ||
| 90 | process, | ||
| 91 | spec: { | ||
| 92 | ...discovery, | ||
| 93 | executable: "fj", | ||
| 94 | versionArgs: ["version"], | ||
| 95 | authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"], | ||
| 96 | parseAuth: (result) => | ||
| 97 | Result.isFailure(credentials) | ||
| 98 | ? providerAuth({ | ||
| 99 | status: "unknown", | ||
| 100 | detail: "Could not read fj authentication storage. Authenticate again with fj.", | ||
| 101 | }) | ||
| 102 | : login && result.exitCode === 0 | ||
| 103 | ? providerAuth({ | ||
| 104 | status: "authenticated", | ||
| 105 | account: login.user, | ||
| 106 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 107 | }) | ||
| 108 | : providerAuth({ | ||
| 109 | status: "unauthenticated", | ||
| 110 | detail: | ||
| 111 | "Authenticate this server with `fj --host <server-url> auth add-token`.", | ||
| 112 | }), | ||
| 113 | }, | ||
| 114 | }); | ||
| 115 | // A configured fj account owns its requests, including authentication errors. | ||
| 116 | if (fj.status === "available" && (login || Result.isFailure(credentials))) { | ||
| 117 | if (login && fj.auth.status === "authenticated" && cli.getAccount) { | ||
| 118 | const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result); | ||
| 119 | return { | ||
| 120 | ...fj, | ||
| 121 | auth: Result.isSuccess(account) | ||
| 122 | ? providerAuth({ | ||
| 123 | status: "authenticated", | ||
| 124 | account: account.success, | ||
| 125 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 126 | }) | ||
| 127 | : providerAuth({ | ||
| 128 | status: "unknown", | ||
| 129 | detail: account.failure.detail, | ||
| 130 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 131 | }), | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | return fj; | ||
| 135 | } | ||
| 136 | const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); | ||
| 137 | return tea.status === "available" || fj.status === "missing" ? tea : fj; | ||
| 138 | }), | ||
| 139 | refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")( | ||
| 140 | function* (input: { | ||
| 141 | readonly cwd: string; | ||
| 142 | readonly context: SourceControlProvider.SourceControlProviderContext; | ||
| 143 | }) { | ||
| 144 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); | ||
| 145 | if (!remote) return null; | ||
| 146 | for (const command of ["fj", "tea"] as const) { | ||
| 147 | const logins = yield* listLogins({ | ||
| 148 | cwd: input.cwd, | ||
| 149 | command, | ||
| 150 | remoteUrl: input.context.remoteUrl, | ||
| 151 | }).pipe(Effect.orElseSucceed(() => [])); | ||
| 152 | const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); | ||
| 153 | if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url }; | ||
| 154 | } | ||
| 155 | return null; | ||
| 156 | }, | ||
| 157 | ), | ||
| 158 | } satisfies SourceControlManagedCliDiscoverySpec; | ||
| 159 | }); | ||
| 160 | |||
| 161 | const RepositorySchema = Schema.Struct({ | ||
| 162 | full_name: Schema.String, | ||
| 163 | clone_url: Schema.String, | ||
| 164 | ssh_url: Schema.String, | ||
| 165 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 166 | }); | ||
| 167 | const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ | ||
| 168 | nameWithOwner: raw.full_name, | ||
| 169 | url: raw.clone_url, | ||
| 170 | sshUrl: raw.ssh_url, | ||
| 171 | }); | ||
| 172 | const repositoryPath = (repository: string) => | ||
| 173 | `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 174 | |||
| 175 | export const make = Effect.gen(function* () { | ||
| 176 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 177 | const fs = yield* FileSystem.FileSystem; | ||
| 178 | const process = yield* VcsProcess.VcsProcess; | ||
| 179 | const request = <S extends Schema.Codec<unknown, unknown, never, never>>( | ||
| 180 | input: ForgejoCli.ForgejoApiInput, | ||
| 181 | schema: S, | ||
| 182 | ) => | ||
| 183 | cli.api(input).pipe( | ||
| 184 | Effect.flatMap((result) => | ||
| 185 | Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe( | ||
| 186 | Effect.mapError( | ||
| 187 | (cause) => | ||
| 188 | new ForgejoCli.ForgejoCliError({ | ||
| 189 | command: "tea", | ||
| 190 | cwd: input.cwd, | ||
| 191 | detail: "Forgejo API returned an invalid response.", | ||
| 192 | reason: "invalid-response", | ||
| 193 | cause, | ||
| 194 | }), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | const mapError = (operation: string, cwd: string) => | ||
| 200 | Effect.mapError( | ||
| 201 | (cause: unknown) => | ||
| 202 | new SourceControlProviderError({ | ||
| 203 | provider: "forgejo", | ||
| 204 | operation, | ||
| 205 | cwd, | ||
| 206 | ...(isForgejoCliError(cause) ? { command: cause.command } : {}), | ||
| 207 | detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.", | ||
| 208 | cause, | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* ( | ||
| 212 | input: Parameters< | ||
| 213 | SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] | ||
| 214 | >[0], | ||
| 215 | ) { | ||
| 216 | const repo = yield* cli.resolveRepository(input); | ||
| 217 | const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1]; | ||
| 218 | if (!number) | ||
| 219 | return yield* new ForgejoCli.ForgejoCliError({ | ||
| 220 | command: "tea", | ||
| 221 | cwd: input.cwd, | ||
| 222 | detail: "Specify a pull request number or Forgejo pull request URL.", | ||
| 223 | }); | ||
| 224 | return yield* request( | ||
| 225 | { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` }, | ||
| 226 | ForgejoPullRequestSchema, | ||
| 227 | ); | ||
| 228 | }); | ||
| 229 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 230 | kind: "forgejo", | ||
| 231 | listChangeRequests: (input) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const repo = yield* cli.resolveRepository(input); | ||
| 234 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 235 | const branch = SourceControlProvider.sourceBranch(input); | ||
| 236 | const results: ReturnType<typeof toForgejoChangeRequest>[] = []; | ||
| 237 | const limit = input.limit ?? 20; | ||
| 238 | for (let page = 1; results.length < limit; page++) { | ||
| 239 | const items = yield* request( | ||
| 240 | { | ||
| 241 | ...input, | ||
| 242 | path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`, | ||
| 243 | }, | ||
| 244 | Schema.Array(ForgejoPullRequestSchema), | ||
| 245 | ); | ||
| 246 | for (const item of items) { | ||
| 247 | if ( | ||
| 248 | item.head.ref !== branch || | ||
| 249 | (source?.repository && item.head.repo?.full_name !== source.repository) || | ||
| 250 | (source?.owner && item.head.repo?.owner.login !== source.owner) | ||
| 251 | ) | ||
| 252 | continue; | ||
| 253 | const normalized = toForgejoChangeRequest(item); | ||
| 254 | if (input.state === "all" || normalized.state === input.state) results.push(normalized); | ||
| 255 | } | ||
| 256 | if (items.length === 0) break; | ||
| 257 | } | ||
| 258 | return results.slice(0, limit); | ||
| 259 | }).pipe(mapError("listChangeRequests", input.cwd)), | ||
| 260 | getChangeRequest: (input) => | ||
| 261 | getPull(input).pipe( | ||
| 262 | Effect.map(toForgejoChangeRequest), | ||
| 263 | mapError("getChangeRequest", input.cwd), | ||
| 264 | ), | ||
| 265 | createChangeRequest: (input) => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const repo = yield* cli.resolveRepository(input); | ||
| 268 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 269 | const owner = source?.owner ?? source?.repository?.split("/")[0]; | ||
| 270 | const head = SourceControlProvider.sourceBranch(input); | ||
| 271 | yield* cli.api({ | ||
| 272 | ...input, | ||
| 273 | path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`, | ||
| 274 | method: "POST", | ||
| 275 | body: { | ||
| 276 | base: input.target?.refName ?? input.baseRefName, | ||
| 277 | head: owner ? `${owner}:${head}` : head, | ||
| 278 | title: input.title, | ||
| 279 | body: yield* fs.readFileString(input.bodyFile), | ||
| 280 | }, | ||
| 281 | }); | ||
| 282 | }).pipe(mapError("createChangeRequest", input.cwd)), | ||
| 283 | getRepositoryCloneUrls: (input) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const repo = yield* cli.resolveRepository(input); | ||
| 286 | return cloneUrls( | ||
| 287 | yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema), | ||
| 288 | ); | ||
| 289 | }).pipe(mapError("getRepositoryCloneUrls", input.cwd)), | ||
| 290 | createRepository: (input) => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const repo = yield* cli.resolveRepository(input); | ||
| 293 | const user = yield* request( | ||
| 294 | { ...input, path: "user" }, | ||
| 295 | Schema.Struct({ login: Schema.String }), | ||
| 296 | ); | ||
| 297 | const [owner, name] = repo.repository.split("/"); | ||
| 298 | return cloneUrls( | ||
| 299 | yield* request( | ||
| 300 | { | ||
| 301 | ...input, | ||
| 302 | path: | ||
| 303 | owner === user.login | ||
| 304 | ? "user/repos" | ||
| 305 | : `orgs/${encodeURIComponent(owner ?? "")}/repos`, | ||
| 306 | method: "POST", | ||
| 307 | body: { name, private: input.visibility === "private", auto_init: false }, | ||
| 308 | }, | ||
| 309 | RepositorySchema, | ||
| 310 | ), | ||
| 311 | ); | ||
| 312 | }).pipe(mapError("createRepository", input.cwd)), | ||
| 313 | getDefaultBranch: (input) => | ||
| 314 | Effect.gen(function* () { | ||
| 315 | const repo = yield* cli.resolveRepository(input); | ||
| 316 | return ( | ||
| 317 | (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema)) | ||
| 318 | .default_branch ?? null | ||
| 319 | ); | ||
| 320 | }).pipe(mapError("getDefaultBranch", input.cwd)), | ||
| 321 | checkoutChangeRequest: (input) => | ||
| 322 | Effect.gen(function* () { | ||
| 323 | const repo = yield* cli.resolveRepository(input); | ||
| 324 | const pull = yield* getPull(input); | ||
| 325 | if (repo.command === "fj") { | ||
| 326 | // fj checkout cannot target a repository outside the local remotes. | ||
| 327 | const urls = yield* request( | ||
| 328 | { ...input, path: repositoryPath(repo.repository) }, | ||
| 329 | RepositorySchema, | ||
| 330 | ); | ||
| 331 | const remote = input.context?.remoteUrl; | ||
| 332 | const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh; | ||
| 333 | yield* process.run({ | ||
| 334 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 335 | command: "git", | ||
| 336 | cwd: input.cwd, | ||
| 337 | args: [ | ||
| 338 | "fetch", | ||
| 339 | "--", | ||
| 340 | useSsh ? urls.ssh_url : urls.clone_url, | ||
| 341 | `refs/pull/${pull.number}/head`, | ||
| 342 | ], | ||
| 343 | }); | ||
| 344 | const branch = `pulls/${pull.number}`; | ||
| 345 | const existing = yield* process.run({ | ||
| 346 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 347 | command: "git", | ||
| 348 | cwd: input.cwd, | ||
| 349 | args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | ||
| 350 | allowNonZeroExit: true, | ||
| 351 | }); | ||
| 352 | yield* process.run({ | ||
| 353 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 354 | command: "git", | ||
| 355 | cwd: input.cwd, | ||
| 356 | args: | ||
| 357 | existing.exitCode === 0 | ||
| 358 | ? ["checkout", branch] | ||
| 359 | : ["checkout", "-b", branch, "FETCH_HEAD"], | ||
| 360 | }); | ||
| 361 | } else | ||
| 362 | yield* cli.execute({ | ||
| 363 | cwd: input.cwd, | ||
| 364 | args: [ | ||
| 365 | "pulls", | ||
| 366 | "checkout", | ||
| 367 | "--login", | ||
| 368 | repo.login, | ||
| 369 | "--repo", | ||
| 370 | repo.repository, | ||
| 371 | "--branch", | ||
| 372 | String(pull.number), | ||
| 373 | ], | ||
| 374 | }); | ||
| 375 | if (input.force) { | ||
| 376 | // tea leaves an existing PR branch at its old tip. Keep dirty files safe | ||
| 377 | // while bringing the selected branch to the PR revision we fetched. | ||
| 378 | yield* process.run({ | ||
| 379 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 380 | command: "git", | ||
| 381 | cwd: input.cwd, | ||
| 382 | args: ["reset", "--keep", pull.head.sha], | ||
| 383 | }); | ||
| 384 | } | ||
| 385 | }).pipe(mapError("checkoutChangeRequest", input.cwd)), | ||
| 386 | }); | ||
| 387 | }); | ||
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+29-1261| ... | @@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | ... | @@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 20 | import * as BitbucketApi from "./BitbucketApi.ts"; | 20 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 21 | import * as GitHubApi from "./GitHubApi.ts"; | 21 | import * as GitHubApi from "./GitHubApi.ts"; |
| 22 | import * as GitLabCli from "./GitLabCli.ts"; | 22 | import * as GitLabCli from "./GitLabCli.ts"; |
| 23 | import * as ForgejoCli from "./ForgejoCli.ts"; | 23 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 24 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | 24 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; |
| 25 | import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; | 25 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; |
| 26 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; | 26 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; |
| 27 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 27 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 28 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | 28 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 29 | import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; | 29 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 30 | 30 | ||
| 31 | const layerSourceControlProviderRegistryTest = (input: { | 31 | const layerSourceControlProviderRegistryTest = (input: { |
| 32 | readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>; | 32 | readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>; |
| ... | @@ -76,434 +76,6 @@ const processOutput = ( | ... | @@ -76,434 +76,6 @@ const processOutput = ( |
| 76 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 76 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 77 | const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 77 | const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 78 | 78 | ||
| 79 | it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { | ||
| 80 | const methods: string[] = []; | ||
| 81 | const fetchReview = async ( | ||
| 82 | ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>> | ||
| 83 | ) => { | ||
| 84 | const request = new Request(input instanceof Request ? input.url : String(input), { | ||
| 85 | ...(init?.method === undefined ? {} : { method: init.method }), | ||
| 86 | ...(init?.headers === undefined ? {} : { headers: init.headers }), | ||
| 87 | ...(init?.body === undefined ? {} : { body: init.body }), | ||
| 88 | }); | ||
| 89 | methods.push(request.method); | ||
| 90 | if (request.method === "GET") { | ||
| 91 | assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); | ||
| 92 | return new Response( | ||
| 93 | encodeJson({ | ||
| 94 | number: 42, | ||
| 95 | title: "Review target", | ||
| 96 | body: "", | ||
| 97 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 98 | user: { login: "maria" }, | ||
| 99 | state: "open", | ||
| 100 | merged: false, | ||
| 101 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 102 | base: { ref: "main", sha: "base", repo: null }, | ||
| 103 | created_at: "2026-09-13T00:00:00Z", | ||
| 104 | updated_at: "2026-09-13T00:00:00Z", | ||
| 105 | closed_at: null, | ||
| 106 | merged_at: null, | ||
| 107 | labels: [], | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | assert.strictEqual(request.method, "POST"); | ||
| 112 | assert.strictEqual( | ||
| 113 | request.url, | ||
| 114 | "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", | ||
| 115 | ); | ||
| 116 | assert.deepStrictEqual(JSON.parse(await request.text()), { | ||
| 117 | event: "COMMENT", | ||
| 118 | body: "Review summary", | ||
| 119 | commit_id: "head", | ||
| 120 | comments: [], | ||
| 121 | }); | ||
| 122 | return new Response('{"id":1}', { status: 200 }); | ||
| 123 | }; | ||
| 124 | return Effect.gen(function* () { | ||
| 125 | const cli = yield* ForgejoCli.make; | ||
| 126 | const provider = yield* ForgejoPullRequestProvider.make.pipe( | ||
| 127 | Effect.provideService(ForgejoCli.ForgejoCli, cli), | ||
| 128 | ); | ||
| 129 | yield* provider.submitReview({ | ||
| 130 | cwd: "/repo", | ||
| 131 | repository: "maria/project", | ||
| 132 | host: "forgejo.test", | ||
| 133 | number: 42, | ||
| 134 | verdict: "comment", | ||
| 135 | body: "Review summary", | ||
| 136 | comments: [], | ||
| 137 | }); | ||
| 138 | assert.deepStrictEqual(methods, ["GET", "POST"]); | ||
| 139 | }).pipe( | ||
| 140 | Effect.provideService( | ||
| 141 | FetchHttpClient.Fetch, | ||
| 142 | Object.assign(fetchReview, { preconnect: () => undefined }), | ||
| 143 | ), | ||
| 144 | Effect.provide(FetchHttpClient.layer), | ||
| 145 | Effect.provideService( | ||
| 146 | FileSystem.FileSystem, | ||
| 147 | FileSystem.makeNoop({ | ||
| 148 | exists: () => Effect.succeed(true), | ||
| 149 | readFileString: () => | ||
| 150 | Effect.succeed( | ||
| 151 | encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), | ||
| 152 | ), | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | Effect.provide( | ||
| 156 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 157 | run: (input) => { | ||
| 158 | if (input.command === "git") { | ||
| 159 | assert.deepStrictEqual(input.args, ["remote", "-v"]); | ||
| 160 | return Effect.succeed( | ||
| 161 | processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | assert.strictEqual(input.command, "fj"); | ||
| 165 | assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); | ||
| 166 | return Effect.succeed(processOutput("")); | ||
| 167 | }, | ||
| 168 | }), | ||
| 169 | ), | ||
| 170 | ); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it.effect("reads Forgejo checks without repository or viewer requests", () => { | ||
| 174 | const paths: string[] = []; | ||
| 175 | return Effect.gen(function* () { | ||
| 176 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 177 | const read = provider.getChangeRequestChecks; | ||
| 178 | if (read === undefined) return yield* Effect.die("checks read missing"); | ||
| 179 | const result = yield* read({ | ||
| 180 | cwd: "/repo", | ||
| 181 | repository: "acme/web", | ||
| 182 | host: "forgejo.test", | ||
| 183 | number: 1, | ||
| 184 | }); | ||
| 185 | assert.strictEqual(result.state, "open"); | ||
| 186 | assert.strictEqual(result.checks[0]?.status, "failure"); | ||
| 187 | assert.deepStrictEqual(paths, [ | ||
| 188 | "repos/acme/web/pulls/1", | ||
| 189 | "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", | ||
| 190 | "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", | ||
| 191 | ]); | ||
| 192 | }).pipe( | ||
| 193 | Effect.provide( | ||
| 194 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 195 | api: (input) => { | ||
| 196 | paths.push(input.path); | ||
| 197 | assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); | ||
| 198 | return Effect.succeed( | ||
| 199 | processOutput( | ||
| 200 | input.path.endsWith("pulls/1") | ||
| 201 | ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, | ||
| 202 | "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, | ||
| 203 | "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` | ||
| 204 | : input.path.endsWith("page=1") | ||
| 205 | ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` | ||
| 206 | : "[]", | ||
| 207 | ), | ||
| 208 | ); | ||
| 209 | }, | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | ); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it.effect.each([ | ||
| 216 | ["Ready", false, true, false, "mergeable"], | ||
| 217 | ["Draft", true, false, true, "unknown"], | ||
| 218 | ["Blocked", false, false, false, "unknown"], | ||
| 219 | ["Unchecked", false, undefined, false, "unknown"], | ||
| 220 | ["WIP: Legacy draft", undefined, false, true, "unknown"], | ||
| 221 | ["[WIP] Legacy draft", undefined, false, true, "unknown"], | ||
| 222 | ["WIP: Explicitly ready", false, false, false, "unknown"], | ||
| 223 | ] as const)( | ||
| 224 | "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", | ||
| 225 | ([title, draft, mergeable, isDraft, mergeability]) => { | ||
| 226 | const pr = { | ||
| 227 | number: 42, | ||
| 228 | title, | ||
| 229 | body: "", | ||
| 230 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 231 | user: { login: "maria" }, | ||
| 232 | state: "open", | ||
| 233 | merged: false, | ||
| 234 | ...(draft === undefined ? {} : { draft }), | ||
| 235 | ...(mergeable === undefined ? {} : { mergeable }), | ||
| 236 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 237 | base: { ref: "main", sha: "base", repo: null }, | ||
| 238 | created_at: "2026-09-16T00:00:00Z", | ||
| 239 | updated_at: "2026-09-16T00:00:00Z", | ||
| 240 | closed_at: null, | ||
| 241 | merged_at: null, | ||
| 242 | labels: [], | ||
| 243 | }; | ||
| 244 | return Effect.gen(function* () { | ||
| 245 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 246 | const readSummary = provider.getChangeRequestSummary; | ||
| 247 | if (readSummary === undefined) return yield* Effect.die("summary read missing"); | ||
| 248 | const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; | ||
| 249 | const list = yield* provider.listChangeRequests({ | ||
| 250 | ...input, | ||
| 251 | state: "open", | ||
| 252 | involvement: "all", | ||
| 253 | viewer: "maria", | ||
| 254 | limit: 10, | ||
| 255 | }); | ||
| 256 | const summary = yield* readSummary(input); | ||
| 257 | const detail = yield* provider.getChangeRequest(input); | ||
| 258 | assert.strictEqual(list.items.length, 1); | ||
| 259 | for (const result of [list.items[0]!, summary, detail]) { | ||
| 260 | assert.strictEqual(result.mergeability, mergeability); | ||
| 261 | assert.strictEqual(result.isDraft, isDraft); | ||
| 262 | } | ||
| 263 | }).pipe( | ||
| 264 | Effect.provide( | ||
| 265 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 266 | api: (input) => { | ||
| 267 | const [path, query] = input.path.split("?"); | ||
| 268 | let response: unknown; | ||
| 269 | switch (path) { | ||
| 270 | case "repos/maria/project/pulls": | ||
| 271 | response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; | ||
| 272 | break; | ||
| 273 | case "repos/maria/project/pulls/42": | ||
| 274 | response = pr; | ||
| 275 | break; | ||
| 276 | case "repos/maria/project": | ||
| 277 | response = { full_name: "maria/project", permissions: { push: true, admin: true } }; | ||
| 278 | break; | ||
| 279 | case "user": | ||
| 280 | response = pr.user; | ||
| 281 | break; | ||
| 282 | case "repos/maria/project/statuses/head": | ||
| 283 | response = []; | ||
| 284 | break; | ||
| 285 | default: | ||
| 286 | return Effect.die(`Unexpected Forgejo request: ${input.path}`); | ||
| 287 | } | ||
| 288 | return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); | ||
| 289 | }, | ||
| 290 | }), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | }, | ||
| 294 | ); | ||
| 295 | |||
| 296 | it.effect("loads Forgejo pull request references from files and commits views", () => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const provider = yield* ForgejoSourceControlProvider.make; | ||
| 299 | for (const reference of [ | ||
| 300 | "42", | ||
| 301 | "#42", | ||
| 302 | "https://forgejo.test/maria/project/pulls/42", | ||
| 303 | "https://forgejo.test/maria/project/pulls/42/", | ||
| 304 | "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", | ||
| 305 | "http://forgejo.test:3000/git/maria/project/pulls/42/commits", | ||
| 306 | ]) { | ||
| 307 | const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); | ||
| 308 | assert.strictEqual(result.number, 42); | ||
| 309 | assert.strictEqual(result.title, "Forgejo view reference"); | ||
| 310 | } | ||
| 311 | const invalid = yield* provider | ||
| 312 | .getChangeRequest({ | ||
| 313 | cwd: "/repo", | ||
| 314 | reference: "https://forgejo.test/maria/project/pulls/42invalid/files", | ||
| 315 | }) | ||
| 316 | .pipe(Effect.result); | ||
| 317 | assert.strictEqual(invalid._tag, "Failure"); | ||
| 318 | }).pipe( | ||
| 319 | Effect.provide( | ||
| 320 | Layer.mergeAll( | ||
| 321 | Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), | ||
| 322 | Layer.mock(VcsProcess.VcsProcess)({}), | ||
| 323 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 324 | resolveRepository: () => | ||
| 325 | Effect.succeed({ | ||
| 326 | login: "work", | ||
| 327 | repository: "maria/project", | ||
| 328 | baseUrl: "https://forgejo.test", | ||
| 329 | }), | ||
| 330 | api: (input) => { | ||
| 331 | assert.strictEqual(input.path, "repos/maria/project/pulls/42"); | ||
| 332 | return encodeJsonEffect({ | ||
| 333 | number: 42, | ||
| 334 | title: "Forgejo view reference", | ||
| 335 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 336 | state: "open", | ||
| 337 | merged: false, | ||
| 338 | base: { ref: "main", sha: "base", repo: null }, | ||
| 339 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 340 | }).pipe(Effect.orDie, Effect.map(processOutput)); | ||
| 341 | }, | ||
| 342 | }), | ||
| 343 | ), | ||
| 344 | ), | ||
| 345 | ), | ||
| 346 | ); | ||
| 347 | |||
| 348 | it.effect( | ||
| 349 | "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", | ||
| 350 | () => { | ||
| 351 | const user = { login: "maria" }; | ||
| 352 | const review = { | ||
| 353 | id: 8, | ||
| 354 | body: "review body", | ||
| 355 | user, | ||
| 356 | state: "COMMENT", | ||
| 357 | submitted_at: "2026-09-12T00:00:00Z", | ||
| 358 | html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", | ||
| 359 | comments_count: 1, | ||
| 360 | }; | ||
| 361 | const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; | ||
| 362 | const responses: Record<string, unknown> = { | ||
| 363 | user, | ||
| 364 | "repos/maria/project/issues/2/comments": [comment], | ||
| 365 | "repos/maria/project/pulls/2/reviews": [review], | ||
| 366 | "repos/maria/project/pulls/2/reviews/8": review, | ||
| 367 | "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, | ||
| 368 | "repos/maria/project/pulls/2/commits": [], | ||
| 369 | "repos/maria/project/issues/2/reactions": [], | ||
| 370 | "repos/maria/project/pulls/2/reviews/8/comments": [ | ||
| 371 | { | ||
| 372 | ...comment, | ||
| 373 | id: 38, | ||
| 374 | body: "inline", | ||
| 375 | path: "file.ts", | ||
| 376 | position: 1, | ||
| 377 | original_position: 1, | ||
| 378 | commit_id: "head", | ||
| 379 | original_commit_id: "head", | ||
| 380 | resolver: null, | ||
| 381 | }, | ||
| 382 | ], | ||
| 383 | "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], | ||
| 384 | "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], | ||
| 385 | "repos/maria/project/issues/comments/38/reactions": [ | ||
| 386 | { content: "rocket", user: { login: "reviewer" } }, | ||
| 387 | ], | ||
| 388 | }; | ||
| 389 | const writes: ForgejoCli.ForgejoApiInput[] = []; | ||
| 390 | let reactionReads = 0; | ||
| 391 | return Effect.gen(function* () { | ||
| 392 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 393 | const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; | ||
| 394 | const activity = yield* provider.getChangeRequestActivity(input); | ||
| 395 | assert.deepStrictEqual( | ||
| 396 | activity.comments.map((entry) => ({ | ||
| 397 | id: entry.id, | ||
| 398 | kind: entry.kind, | ||
| 399 | reactions: entry.reactions, | ||
| 400 | })), | ||
| 401 | [ | ||
| 402 | { | ||
| 403 | id: "12", | ||
| 404 | kind: "issue-comment", | ||
| 405 | reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], | ||
| 406 | }, | ||
| 407 | { | ||
| 408 | id: "review:8", | ||
| 409 | kind: "review", | ||
| 410 | reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], | ||
| 411 | }, | ||
| 412 | { | ||
| 413 | id: "38", | ||
| 414 | kind: "review-comment", | ||
| 415 | reactions: [ | ||
| 416 | { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, | ||
| 417 | ], | ||
| 418 | }, | ||
| 419 | ], | ||
| 420 | ); | ||
| 421 | const inlineComment = activity.comments[2]; | ||
| 422 | assert.ok(inlineComment); | ||
| 423 | assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); | ||
| 424 | for (const reacted of [true, false]) { | ||
| 425 | yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); | ||
| 426 | yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); | ||
| 427 | } | ||
| 428 | assert.deepStrictEqual( | ||
| 429 | writes.map(({ path, method, body }) => ({ path, method, body })), | ||
| 430 | [ | ||
| 431 | { | ||
| 432 | path: "repos/maria/project/issues/comments/37/reactions", | ||
| 433 | method: "POST", | ||
| 434 | body: { content: "heart" }, | ||
| 435 | }, | ||
| 436 | { | ||
| 437 | path: "repos/maria/project/issues/comments/38/reactions", | ||
| 438 | method: "POST", | ||
| 439 | body: { content: "rocket" }, | ||
| 440 | }, | ||
| 441 | { | ||
| 442 | path: "repos/maria/project/issues/comments/37/reactions", | ||
| 443 | method: "DELETE", | ||
| 444 | body: { content: "heart" }, | ||
| 445 | }, | ||
| 446 | { | ||
| 447 | path: "repos/maria/project/issues/comments/38/reactions", | ||
| 448 | method: "DELETE", | ||
| 449 | body: { content: "rocket" }, | ||
| 450 | }, | ||
| 451 | ], | ||
| 452 | ); | ||
| 453 | const missing = yield* provider | ||
| 454 | .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) | ||
| 455 | .pipe(Effect.result); | ||
| 456 | assert.strictEqual(missing._tag, "Failure"); | ||
| 457 | if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); | ||
| 458 | assert.strictEqual(writes.length, 4); | ||
| 459 | responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( | ||
| 460 | { length: 501 }, | ||
| 461 | (_, index) => ({ | ||
| 462 | ...comment, | ||
| 463 | id: 1000 + index, | ||
| 464 | path: "file.ts", | ||
| 465 | position: 1, | ||
| 466 | original_position: 1, | ||
| 467 | commit_id: "head", | ||
| 468 | original_commit_id: "head", | ||
| 469 | resolver: null, | ||
| 470 | }), | ||
| 471 | ); | ||
| 472 | for (let index = 0; index < 500; index++) { | ||
| 473 | responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; | ||
| 474 | } | ||
| 475 | reactionReads = 0; | ||
| 476 | const bounded = yield* provider.getChangeRequestActivity(input); | ||
| 477 | assert.strictEqual(bounded.reviewThreads.length, 500); | ||
| 478 | assert.strictEqual( | ||
| 479 | bounded.comments.filter((entry) => entry.kind === "review-comment").length, | ||
| 480 | 500, | ||
| 481 | ); | ||
| 482 | assert.strictEqual(bounded.commentsTruncated, true); | ||
| 483 | assert.strictEqual(reactionReads, 502); | ||
| 484 | }).pipe( | ||
| 485 | Effect.provide( | ||
| 486 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 487 | api: (input) => { | ||
| 488 | if (input.method) { | ||
| 489 | writes.push(input); | ||
| 490 | return Effect.succeed(processOutput("{}")); | ||
| 491 | } | ||
| 492 | const path = input.path.split("?")[0]!; | ||
| 493 | if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; | ||
| 494 | assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); | ||
| 495 | const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); | ||
| 496 | return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( | ||
| 497 | Effect.orDie, | ||
| 498 | Effect.map(processOutput), | ||
| 499 | ); | ||
| 500 | }, | ||
| 501 | }), | ||
| 502 | ), | ||
| 503 | ); | ||
| 504 | }, | ||
| 505 | ); | ||
| 506 | |||
| 507 | it.effect("reports implemented tools separately from locally available executables", () => { | 79 | it.effect("reports implemented tools separately from locally available executables", () => { |
| 508 | const processMock = { | 80 | const processMock = { |
| 509 | run: (input: VcsProcess.VcsProcessInput) => { | 81 | run: (input: VcsProcess.VcsProcessInput) => { |
| ... | @@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user | ... | @@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user |
| 764 | }).pipe(Effect.provide(layerTest)); | 336 | }).pipe(Effect.provide(layerTest)); |
| 765 | }); | 337 | }); |
| 766 | 338 | ||
| 767 | it.effect("discovers Forgejo accounts and retains the server port", () => | ||
| 768 | Effect.gen(function* () { | ||
| 769 | const auth = ForgejoSourceControlProvider.discovery.parseAuth( | ||
| 770 | processOutput( | ||
| 771 | yield* encodeJsonEffect([ | ||
| 772 | { | ||
| 773 | name: "work", | ||
| 774 | url: "http://forgejo.local:3000", | ||
| 775 | ssh_host: "git.forgejo.local", | ||
| 776 | user: "maria", | ||
| 777 | default: "true", | ||
| 778 | valid: "true", | ||
| 779 | }, | ||
| 780 | ]), | ||
| 781 | ), | ||
| 782 | ); | ||
| 783 | assert.deepStrictEqual( | ||
| 784 | firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), | ||
| 785 | Option.some("tea version 0.16.0"), | ||
| 786 | ); | ||
| 787 | assert.strictEqual(auth.status, "authenticated"); | ||
| 788 | assert.deepStrictEqual(auth.account, Option.some("maria")); | ||
| 789 | assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); | ||
| 790 | const revoked = ForgejoSourceControlProvider.discovery.parseAuth( | ||
| 791 | processOutput( | ||
| 792 | encodeJson([ | ||
| 793 | { | ||
| 794 | name: "work", | ||
| 795 | url: "http://forgejo.local:3000", | ||
| 796 | user: "maria", | ||
| 797 | default: "true", | ||
| 798 | valid: "false", | ||
| 799 | }, | ||
| 800 | ]), | ||
| 801 | ), | ||
| 802 | ); | ||
| 803 | assert.strictEqual(revoked.status, "unauthenticated"); | ||
| 804 | const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ | ||
| 805 | cwd: "/repo", | ||
| 806 | context: { | ||
| 807 | provider: { | ||
| 808 | kind: "unknown", | ||
| 809 | name: "git.forgejo.local", | ||
| 810 | baseUrl: "https://git.forgejo.local", | ||
| 811 | }, | ||
| 812 | remoteName: "origin", | ||
| 813 | remoteUrl: "git@git.forgejo.local:maria/project.git", | ||
| 814 | }, | ||
| 815 | auth: processOutput( | ||
| 816 | yield* encodeJsonEffect([ | ||
| 817 | { | ||
| 818 | name: "work", | ||
| 819 | url: "http://forgejo.local:3000", | ||
| 820 | ssh_host: "git.forgejo.local", | ||
| 821 | user: "maria", | ||
| 822 | default: "true", | ||
| 823 | }, | ||
| 824 | ]), | ||
| 825 | ), | ||
| 826 | }); | ||
| 827 | assert.deepStrictEqual(refined, { | ||
| 828 | kind: "forgejo", | ||
| 829 | name: "Forgejo / Gitea", | ||
| 830 | baseUrl: "http://forgejo.local:3000", | ||
| 831 | }); | ||
| 832 | }), | ||
| 833 | ); | ||
| 834 | |||
| 835 | it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => | ||
| 836 | Effect.gen(function* () { | ||
| 837 | const logins = ForgejoCli.parseForgejoLogins( | ||
| 838 | yield* encodeJsonEffect([ | ||
| 839 | { | ||
| 840 | name: "one", | ||
| 841 | url: "http://forgejo.local:3000", | ||
| 842 | ssh_host: "forgejo.local", | ||
| 843 | user: "maria", | ||
| 844 | default: "true", | ||
| 845 | }, | ||
| 846 | { | ||
| 847 | name: "two", | ||
| 848 | url: "http://forgejo.local:4000", | ||
| 849 | ssh_host: "forgejo.local", | ||
| 850 | user: "maria", | ||
| 851 | default: "false", | ||
| 852 | }, | ||
| 853 | ]), | ||
| 854 | ); | ||
| 855 | const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); | ||
| 856 | assert.isNotNull(remote); | ||
| 857 | assert.deepStrictEqual( | ||
| 858 | ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), | ||
| 859 | remote, | ||
| 860 | ); | ||
| 861 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); | ||
| 862 | assert.strictEqual( | ||
| 863 | ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, | ||
| 864 | "two", | ||
| 865 | ); | ||
| 866 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); | ||
| 867 | const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); | ||
| 868 | assert.isNotNull(alias); | ||
| 869 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); | ||
| 870 | const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ | ||
| 871 | cwd: "/repo", | ||
| 872 | context: { | ||
| 873 | provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, | ||
| 874 | remoteName: "origin", | ||
| 875 | remoteUrl: "git@forgejo.local:maria/project.git", | ||
| 876 | requestedHost: "forgejo.local:4000", | ||
| 877 | }, | ||
| 878 | auth: processOutput(yield* encodeJsonEffect(logins)), | ||
| 879 | }); | ||
| 880 | assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); | ||
| 881 | const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); | ||
| 882 | assert.isNotNull(https); | ||
| 883 | assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); | ||
| 884 | const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); | ||
| 885 | assert.strictEqual( | ||
| 886 | ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, | ||
| 887 | "two", | ||
| 888 | ); | ||
| 889 | const mounted = logins.map((login) => ({ | ||
| 890 | ...login, | ||
| 891 | url: `http://forgejo.local:4000/${login.name}`, | ||
| 892 | })); | ||
| 893 | assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); | ||
| 894 | }), | ||
| 895 | ); | ||
| 896 | |||
| 897 | it.effect("rejects HTTP failures even when tea exits successfully", () => | ||
| 898 | Effect.gen(function* () { | ||
| 899 | const cli = yield* ForgejoCli.make; | ||
| 900 | const result = yield* cli | ||
| 901 | .api({ | ||
| 902 | cwd: "/repo", | ||
| 903 | repository: "http://forgejo.local:3000/maria/project", | ||
| 904 | path: "repos/maria/project/pulls/42", | ||
| 905 | method: "PATCH", | ||
| 906 | body: { state: "closed" }, | ||
| 907 | }) | ||
| 908 | .pipe(Effect.result); | ||
| 909 | assert.strictEqual(result._tag, "Failure"); | ||
| 910 | if (result._tag === "Failure") | ||
| 911 | assert.strictEqual( | ||
| 912 | result.failure.detail, | ||
| 913 | "Forgejo repository or pull request was not found.", | ||
| 914 | ); | ||
| 915 | }).pipe( | ||
| 916 | Effect.provideService( | ||
| 917 | FileSystem.FileSystem, | ||
| 918 | FileSystem.makeNoop({ | ||
| 919 | exists: () => Effect.succeed(false), | ||
| 920 | }), | ||
| 921 | ), | ||
| 922 | Effect.provideService( | ||
| 923 | HttpClient.HttpClient, | ||
| 924 | HttpClient.make(() => { | ||
| 925 | throw new Error("tea must handle its own HTTP request"); | ||
| 926 | }), | ||
| 927 | ), | ||
| 928 | Effect.provide( | ||
| 929 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 930 | run: (input) => { | ||
| 931 | if (input.args[0] === "api") { | ||
| 932 | assert.strictEqual(input.stdin, '{"state":"closed"}'); | ||
| 933 | assert.include(input.args, "work"); | ||
| 934 | assert.include( | ||
| 935 | input.args, | ||
| 936 | "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", | ||
| 937 | ); | ||
| 938 | } | ||
| 939 | return Effect.succeed( | ||
| 940 | input.args[0] === "login" | ||
| 941 | ? processOutput( | ||
| 942 | encodeJson([ | ||
| 943 | { | ||
| 944 | name: "work", | ||
| 945 | url: "http://forgejo.local:3000", | ||
| 946 | ssh_host: "forgejo.local", | ||
| 947 | user: "maria", | ||
| 948 | default: "true", | ||
| 949 | }, | ||
| 950 | ]), | ||
| 951 | ) | ||
| 952 | : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), | ||
| 953 | ); | ||
| 954 | }, | ||
| 955 | }), | ||
| 956 | ), | ||
| 957 | ), | ||
| 958 | ); | ||
| 959 | |||
| 960 | it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => | ||
| 961 | Effect.gen(function* () { | ||
| 962 | const cli = yield* ForgejoCli.make; | ||
| 963 | const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); | ||
| 964 | assert.strictEqual(viewer.stdout, "[]"); | ||
| 965 | const mountedRepository = yield* cli.resolveRepository({ | ||
| 966 | cwd: "/upstream-only", | ||
| 967 | host: "code.test", | ||
| 968 | repository: "maria/project", | ||
| 969 | }); | ||
| 970 | assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); | ||
| 971 | assert.strictEqual(mountedRepository.repository, "maria/project"); | ||
| 972 | for (const path of [ | ||
| 973 | "repos/forgejo/maria/project/pulls?state=open", | ||
| 974 | "repos/forgejo/maria/project", | ||
| 975 | "repos/reviewer/project/contents/file.ts", | ||
| 976 | ]) { | ||
| 977 | const result = yield* cli.api({ | ||
| 978 | cwd: "/repo", | ||
| 979 | repository: "forgejo/maria/project", | ||
| 980 | context: { | ||
| 981 | provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, | ||
| 982 | remoteName: "origin", | ||
| 983 | remoteUrl: "https://code.test/forgejo/maria/project.git", | ||
| 984 | }, | ||
| 985 | path, | ||
| 986 | }); | ||
| 987 | assert.strictEqual(result.stdout, "[]"); | ||
| 988 | } | ||
| 989 | const sameOwnerAsMount = yield* cli.resolveRepository({ | ||
| 990 | cwd: "/repo", | ||
| 991 | repository: "forgejo/project", | ||
| 992 | context: { | ||
| 993 | provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, | ||
| 994 | remoteName: "origin", | ||
| 995 | remoteUrl: "ssh://git@code.test/forgejo/project.git", | ||
| 996 | }, | ||
| 997 | }); | ||
| 998 | assert.strictEqual(sameOwnerAsMount.command, "tea"); | ||
| 999 | assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); | ||
| 1000 | }).pipe( | ||
| 1001 | Effect.provideService( | ||
| 1002 | FileSystem.FileSystem, | ||
| 1003 | FileSystem.makeNoop({ | ||
| 1004 | exists: () => Effect.succeed(true), | ||
| 1005 | readFileString: () => | ||
| 1006 | Effect.succeed( | ||
| 1007 | encodeJson({ | ||
| 1008 | hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, | ||
| 1009 | }), | ||
| 1010 | ), | ||
| 1011 | }), | ||
| 1012 | ), | ||
| 1013 | Effect.provideService( | ||
| 1014 | HttpClient.HttpClient, | ||
| 1015 | HttpClient.make(() => { | ||
| 1016 | throw new Error("tea must handle its own HTTP request"); | ||
| 1017 | }), | ||
| 1018 | ), | ||
| 1019 | Effect.provide( | ||
| 1020 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1021 | run: (input) => { | ||
| 1022 | if (input.command === "git") | ||
| 1023 | return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })); | ||
| 1024 | if (input.args[0] === "login") | ||
| 1025 | return Effect.succeed( | ||
| 1026 | processOutput( | ||
| 1027 | encodeJson([ | ||
| 1028 | { | ||
| 1029 | name: "mounted", | ||
| 1030 | url: "https://code.test/forgejo", | ||
| 1031 | ssh_host: "code.test", | ||
| 1032 | user: "maria", | ||
| 1033 | default: "true", | ||
| 1034 | }, | ||
| 1035 | ]), | ||
| 1036 | ), | ||
| 1037 | ); | ||
| 1038 | const supported = [ | ||
| 1039 | "https://code.test/forgejo/api/v1/user", | ||
| 1040 | "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", | ||
| 1041 | "https://code.test/forgejo/api/v1/repos/maria/project", | ||
| 1042 | "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", | ||
| 1043 | ]; | ||
| 1044 | if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); | ||
| 1045 | assert.strictEqual(input.command, "tea"); | ||
| 1046 | return Effect.succeed( | ||
| 1047 | supported.includes(input.args.at(-1) ?? "") | ||
| 1048 | ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) | ||
| 1049 | : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), | ||
| 1050 | ); | ||
| 1051 | }, | ||
| 1052 | }), | ||
| 1053 | ), | ||
| 1054 | ), | ||
| 1055 | ); | ||
| 1056 | |||
| 1057 | it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { | ||
| 1058 | const commands: string[] = []; | ||
| 1059 | const requests: string[] = []; | ||
| 1060 | return Effect.gen(function* () { | ||
| 1061 | const cli = yield* ForgejoCli.make; | ||
| 1062 | for (const remoteUrl of [ | ||
| 1063 | "http://forgejo.local:3000/maria/project.git", | ||
| 1064 | "ssh://git@ssh.forgejo.local:2222/maria/project.git", | ||
| 1065 | "ssh://git@forgejo.local:2222/maria/project.git", | ||
| 1066 | ]) { | ||
| 1067 | const result = yield* cli.api({ | ||
| 1068 | cwd: "/repo", | ||
| 1069 | repository: "maria/project", | ||
| 1070 | context: { | ||
| 1071 | provider: { | ||
| 1072 | kind: "forgejo", | ||
| 1073 | name: "Forgejo", | ||
| 1074 | baseUrl: "http://forgejo.local:3000", | ||
| 1075 | }, | ||
| 1076 | remoteName: "origin", | ||
| 1077 | remoteUrl, | ||
| 1078 | requestedHost: "forgejo.local:3000", | ||
| 1079 | }, | ||
| 1080 | path: "repos/maria/project/issues/42/comments", | ||
| 1081 | method: "POST", | ||
| 1082 | body: { body: "verified through fj" }, | ||
| 1083 | }); | ||
| 1084 | assert.strictEqual(result.stdout, '{"id":99}'); | ||
| 1085 | } | ||
| 1086 | assert.deepStrictEqual(commands, ["fj"]); | ||
| 1087 | assert.deepStrictEqual(requests, [ | ||
| 1088 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 1089 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 1090 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 1091 | ]); | ||
| 1092 | const viewer = yield* cli.api({ | ||
| 1093 | cwd: "/no-remotes", | ||
| 1094 | host: "forgejo.local:3000", | ||
| 1095 | path: "user", | ||
| 1096 | }); | ||
| 1097 | assert.strictEqual(viewer.stdout, '{"login":"maria"}'); | ||
| 1098 | assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); | ||
| 1099 | const upstreamViewer = yield* cli.api({ | ||
| 1100 | cwd: "/upstream-only", | ||
| 1101 | host: "forgejo.local:3000", | ||
| 1102 | path: "user", | ||
| 1103 | }); | ||
| 1104 | assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); | ||
| 1105 | assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); | ||
| 1106 | const upstreamRepository = yield* cli.resolveRepository({ | ||
| 1107 | cwd: "/upstream-only", | ||
| 1108 | host: "forgejo.local:3000", | ||
| 1109 | repository: "maria/project", | ||
| 1110 | }); | ||
| 1111 | assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); | ||
| 1112 | assert.strictEqual(upstreamRepository.repository, "maria/project"); | ||
| 1113 | const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); | ||
| 1114 | assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); | ||
| 1115 | assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); | ||
| 1116 | }).pipe( | ||
| 1117 | Effect.provideService( | ||
| 1118 | FileSystem.FileSystem, | ||
| 1119 | FileSystem.makeNoop({ | ||
| 1120 | exists: () => Effect.succeed(true), | ||
| 1121 | readFileString: () => | ||
| 1122 | Effect.succeed( | ||
| 1123 | encodeJson({ | ||
| 1124 | hosts: { | ||
| 1125 | "forgejo.local:3000": { type: "Application", token: "test-token" }, | ||
| 1126 | "forgejo.local:4000": { type: "Application", token: "other-token" }, | ||
| 1127 | }, | ||
| 1128 | aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, | ||
| 1129 | }), | ||
| 1130 | ), | ||
| 1131 | }), | ||
| 1132 | ), | ||
| 1133 | Effect.provideService( | ||
| 1134 | HttpClient.HttpClient, | ||
| 1135 | HttpClient.make((request) => { | ||
| 1136 | requests.push(request.url); | ||
| 1137 | if (request.url.endsWith("/user")) { | ||
| 1138 | assert.strictEqual(request.method, "GET"); | ||
| 1139 | assert.strictEqual(request.headers.authorization, "token test-token"); | ||
| 1140 | return Effect.succeed( | ||
| 1141 | HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), | ||
| 1142 | ); | ||
| 1143 | } | ||
| 1144 | assert.strictEqual(request.method, "POST"); | ||
| 1145 | assert.strictEqual(request.headers.authorization, "token test-token"); | ||
| 1146 | assert.strictEqual(request.body._tag, "Uint8Array"); | ||
| 1147 | if (request.body._tag === "Uint8Array") | ||
| 1148 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { | ||
| 1149 | body: "verified through fj", | ||
| 1150 | }); | ||
| 1151 | return Effect.succeed( | ||
| 1152 | HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), | ||
| 1153 | ); | ||
| 1154 | }), | ||
| 1155 | ), | ||
| 1156 | Effect.provide( | ||
| 1157 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1158 | run: (input) => { | ||
| 1159 | commands.push(input.command); | ||
| 1160 | if (input.command === "git") | ||
| 1161 | return Effect.succeed( | ||
| 1162 | input.cwd === "/no-remotes" | ||
| 1163 | ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) | ||
| 1164 | : processOutput( | ||
| 1165 | `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, | ||
| 1166 | ), | ||
| 1167 | ); | ||
| 1168 | assert.strictEqual(input.command, "fj"); | ||
| 1169 | assert.deepStrictEqual(input.args, [ | ||
| 1170 | "--host", | ||
| 1171 | input.cwd === "/no-remotes" | ||
| 1172 | ? "https://forgejo.local:3000" | ||
| 1173 | : "http://forgejo.local:3000", | ||
| 1174 | "whoami", | ||
| 1175 | ]); | ||
| 1176 | return Effect.succeed(processOutput("")); | ||
| 1177 | }, | ||
| 1178 | }), | ||
| 1179 | ), | ||
| 1180 | ); | ||
| 1181 | }); | ||
| 1182 | |||
| 1183 | it.effect("loads later fj review pages when the server caps pages below the requested size", () => { | ||
| 1184 | const pages: number[] = []; | ||
| 1185 | let issueCommentRequests = 0; | ||
| 1186 | return Effect.gen(function* () { | ||
| 1187 | const cli = yield* ForgejoCli.make; | ||
| 1188 | const provider = yield* ForgejoPullRequestProvider.make.pipe( | ||
| 1189 | Effect.provideService(ForgejoCli.ForgejoCli, cli), | ||
| 1190 | ); | ||
| 1191 | const activity = yield* provider.getChangeRequestActivity({ | ||
| 1192 | cwd: "/repo", | ||
| 1193 | repository: "maria/project", | ||
| 1194 | host: "forgejo.test", | ||
| 1195 | number: 42, | ||
| 1196 | }); | ||
| 1197 | assert.strictEqual(activity.commentCount, 42); | ||
| 1198 | assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); | ||
| 1199 | assert.strictEqual(activity.commentsTruncated, false); | ||
| 1200 | assert.deepStrictEqual(pages, [1, 2, 3]); | ||
| 1201 | assert.strictEqual(issueCommentRequests, 1); | ||
| 1202 | }).pipe( | ||
| 1203 | Effect.provideService( | ||
| 1204 | FileSystem.FileSystem, | ||
| 1205 | FileSystem.makeNoop({ | ||
| 1206 | exists: () => Effect.succeed(true), | ||
| 1207 | readFileString: () => | ||
| 1208 | Effect.succeed( | ||
| 1209 | encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), | ||
| 1210 | ), | ||
| 1211 | }), | ||
| 1212 | ), | ||
| 1213 | Effect.provideService( | ||
| 1214 | HttpClient.HttpClient, | ||
| 1215 | HttpClient.make((request) => { | ||
| 1216 | const url = new URL(request.url); | ||
| 1217 | if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { | ||
| 1218 | issueCommentRequests++; | ||
| 1219 | return Effect.succeed( | ||
| 1220 | HttpClientResponse.fromWeb( | ||
| 1221 | request, | ||
| 1222 | new Response( | ||
| 1223 | encodeJson([ | ||
| 1224 | { | ||
| 1225 | id: 100, | ||
| 1226 | body: "Unpaginated issue comment", | ||
| 1227 | user: { login: "maria" }, | ||
| 1228 | created_at: "2026-09-13T00:00:00Z", | ||
| 1229 | }, | ||
| 1230 | ]), | ||
| 1231 | ), | ||
| 1232 | ), | ||
| 1233 | ); | ||
| 1234 | } | ||
| 1235 | if (url.pathname.endsWith("/reviews")) { | ||
| 1236 | const page = Number(url.searchParams.get("page")); | ||
| 1237 | pages.push(page); | ||
| 1238 | assert.ok(page >= 1 && page <= 3); | ||
| 1239 | const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ | ||
| 1240 | id: (page - 1) * 20 + index + 1, | ||
| 1241 | body: "Review from a capped page", | ||
| 1242 | user: { login: "maria" }, | ||
| 1243 | state: "COMMENT", | ||
| 1244 | submitted_at: "2026-09-13T00:00:00Z", | ||
| 1245 | comments_count: 0, | ||
| 1246 | })); | ||
| 1247 | return Effect.succeed( | ||
| 1248 | HttpClientResponse.fromWeb( | ||
| 1249 | request, | ||
| 1250 | new Response(encodeJson(reviews), { | ||
| 1251 | headers: | ||
| 1252 | page === 2 | ||
| 1253 | ? {} | ||
| 1254 | : { | ||
| 1255 | Link: | ||
| 1256 | page === 1 | ||
| 1257 | ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` | ||
| 1258 | : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, | ||
| 1259 | }, | ||
| 1260 | }), | ||
| 1261 | ), | ||
| 1262 | ); | ||
| 1263 | } | ||
| 1264 | return Effect.succeed( | ||
| 1265 | HttpClientResponse.fromWeb( | ||
| 1266 | request, | ||
| 1267 | new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), | ||
| 1268 | ), | ||
| 1269 | ); | ||
| 1270 | }), | ||
| 1271 | ), | ||
| 1272 | Effect.provide( | ||
| 1273 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1274 | run: (input) => { | ||
| 1275 | if (input.command === "git") { | ||
| 1276 | assert.deepStrictEqual(input.args, ["remote", "-v"]); | ||
| 1277 | return Effect.succeed( | ||
| 1278 | processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), | ||
| 1279 | ); | ||
| 1280 | } | ||
| 1281 | assert.strictEqual(input.command, "fj"); | ||
| 1282 | assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); | ||
| 1283 | return Effect.succeed(processOutput("")); | ||
| 1284 | }, | ||
| 1285 | }), | ||
| 1286 | ), | ||
| 1287 | ); | ||
| 1288 | }); | ||
| 1289 | |||
| 1290 | it.effect("falls back to tea when fj is missing or has no account for this server", () => | ||
| 1291 | Effect.gen(function* () { | ||
| 1292 | for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { | ||
| 1293 | const commands: string[] = []; | ||
| 1294 | yield* Effect.gen(function* () { | ||
| 1295 | const cli = yield* ForgejoCli.make; | ||
| 1296 | const result = yield* cli.api({ | ||
| 1297 | cwd: "/repo", | ||
| 1298 | repository: "https://forgejo.local:3000/maria/project", | ||
| 1299 | path: "repos/maria/project/pulls", | ||
| 1300 | }); | ||
| 1301 | assert.strictEqual(result.stdout, "[]"); | ||
| 1302 | assert.deepStrictEqual( | ||
| 1303 | commands, | ||
| 1304 | scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], | ||
| 1305 | ); | ||
| 1306 | const viewer = yield* cli.api({ | ||
| 1307 | cwd: "/upstream-only", | ||
| 1308 | host: "forgejo.local:3000", | ||
| 1309 | path: "user", | ||
| 1310 | }); | ||
| 1311 | assert.strictEqual(viewer.stdout, "[]"); | ||
| 1312 | }).pipe( | ||
| 1313 | Effect.provideService( | ||
| 1314 | FileSystem.FileSystem, | ||
| 1315 | FileSystem.makeNoop({ | ||
| 1316 | exists: () => Effect.succeed(true), | ||
| 1317 | readFileString: () => | ||
| 1318 | Effect.succeed( | ||
| 1319 | scenario === "stale-invalid-storage" | ||
| 1320 | ? "invalid json" | ||
| 1321 | : encodeJson({ | ||
| 1322 | hosts: { | ||
| 1323 | [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { | ||
| 1324 | type: "Application", | ||
| 1325 | token: "test-token", | ||
| 1326 | }, | ||
| 1327 | }, | ||
| 1328 | }), | ||
| 1329 | ), | ||
| 1330 | }), | ||
| 1331 | ), | ||
| 1332 | Effect.provideService( | ||
| 1333 | HttpClient.HttpClient, | ||
| 1334 | HttpClient.make(() => { | ||
| 1335 | throw new Error("tea must handle its own HTTP request"); | ||
| 1336 | }), | ||
| 1337 | ), | ||
| 1338 | Effect.provide( | ||
| 1339 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1340 | run: (input) => { | ||
| 1341 | commands.push(input.command); | ||
| 1342 | if (input.command === "git") | ||
| 1343 | return Effect.succeed( | ||
| 1344 | processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), | ||
| 1345 | ); | ||
| 1346 | if (input.command === "fj") | ||
| 1347 | return Effect.fail( | ||
| 1348 | new VcsProcessSpawnError({ | ||
| 1349 | operation: input.operation, | ||
| 1350 | command: input.command, | ||
| 1351 | cwd: input.cwd, | ||
| 1352 | cause: new Error("fj not found"), | ||
| 1353 | }), | ||
| 1354 | ); | ||
| 1355 | assert.strictEqual(input.command, "tea"); | ||
| 1356 | if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); | ||
| 1357 | return Effect.succeed( | ||
| 1358 | input.args[0] === "login" | ||
| 1359 | ? processOutput( | ||
| 1360 | encodeJson([ | ||
| 1361 | { | ||
| 1362 | name: "work", | ||
| 1363 | url: "https://forgejo.local:3000", | ||
| 1364 | user: "maria", | ||
| 1365 | default: "true", | ||
| 1366 | valid: "true", | ||
| 1367 | }, | ||
| 1368 | ]), | ||
| 1369 | ) | ||
| 1370 | : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), | ||
| 1371 | ); | ||
| 1372 | }, | ||
| 1373 | }), | ||
| 1374 | ), | ||
| 1375 | ); | ||
| 1376 | } | ||
| 1377 | }), | ||
| 1378 | ); | ||
| 1379 | |||
| 1380 | it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => | ||
| 1381 | Effect.gen(function* () { | ||
| 1382 | for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { | ||
| 1383 | let writes = 0; | ||
| 1384 | yield* Effect.gen(function* () { | ||
| 1385 | const cli = yield* ForgejoCli.make; | ||
| 1386 | const result = yield* cli | ||
| 1387 | .api({ | ||
| 1388 | cwd: "/repo", | ||
| 1389 | repository: "https://forgejo.local/maria/project", | ||
| 1390 | path: "repos/maria/project/issues/42/comments", | ||
| 1391 | method: "POST", | ||
| 1392 | body: { body: "only once" }, | ||
| 1393 | }) | ||
| 1394 | .pipe(Effect.result); | ||
| 1395 | assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); | ||
| 1396 | if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); | ||
| 1397 | if (result._tag === "Failure") { | ||
| 1398 | assert.strictEqual(result.failure.command, "fj"); | ||
| 1399 | assert.strictEqual(result.failure.httpStatus, status); | ||
| 1400 | } | ||
| 1401 | assert.strictEqual(writes, 1); | ||
| 1402 | }).pipe( | ||
| 1403 | Effect.provideService( | ||
| 1404 | FileSystem.FileSystem, | ||
| 1405 | FileSystem.makeNoop({ | ||
| 1406 | exists: () => Effect.succeed(true), | ||
| 1407 | readFileString: () => | ||
| 1408 | Effect.succeed( | ||
| 1409 | encodeJson({ | ||
| 1410 | hosts: { | ||
| 1411 | "forgejo.local": { type: "Application", token: "test-token" }, | ||
| 1412 | }, | ||
| 1413 | }), | ||
| 1414 | ), | ||
| 1415 | }), | ||
| 1416 | ), | ||
| 1417 | Effect.provideService( | ||
| 1418 | HttpClient.HttpClient, | ||
| 1419 | HttpClient.make((request) => { | ||
| 1420 | writes++; | ||
| 1421 | assert.strictEqual( | ||
| 1422 | request.url, | ||
| 1423 | "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", | ||
| 1424 | ); | ||
| 1425 | return Effect.succeed( | ||
| 1426 | HttpClientResponse.fromWeb( | ||
| 1427 | request, | ||
| 1428 | new Response(status < 300 ? null : "", { | ||
| 1429 | status, | ||
| 1430 | headers: { location: "https://other.local/" }, | ||
| 1431 | }), | ||
| 1432 | ), | ||
| 1433 | ); | ||
| 1434 | }), | ||
| 1435 | ), | ||
| 1436 | Effect.provide( | ||
| 1437 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1438 | run: (input) => { | ||
| 1439 | assert.strictEqual( | ||
| 1440 | input.command, | ||
| 1441 | "fj", | ||
| 1442 | "a failed mutation must never switch accounts or CLI", | ||
| 1443 | ); | ||
| 1444 | return Effect.succeed(processOutput("")); | ||
| 1445 | }, | ||
| 1446 | }), | ||
| 1447 | ), | ||
| 1448 | ); | ||
| 1449 | } | ||
| 1450 | }), | ||
| 1451 | ); | ||
| 1452 | |||
| 1453 | it.effect( | ||
| 1454 | "discovers fj first and retains configured authentication failures instead of switching accounts", | ||
| 1455 | () => | ||
| 1456 | Effect.gen(function* () { | ||
| 1457 | for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { | ||
| 1458 | const commands: string[] = []; | ||
| 1459 | yield* Effect.gen(function* () { | ||
| 1460 | const spec = yield* ForgejoSourceControlProvider.makeDiscovery; | ||
| 1461 | assert.strictEqual(spec.type, "managed-cli"); | ||
| 1462 | if (spec.type !== "managed-cli") return; | ||
| 1463 | const result = yield* spec.probe("/repo"); | ||
| 1464 | assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); | ||
| 1465 | assert.strictEqual( | ||
| 1466 | result.auth.status, | ||
| 1467 | scenario === "revoked" | ||
| 1468 | ? "unauthenticated" | ||
| 1469 | : scenario === "invalid-storage" | ||
| 1470 | ? "unknown" | ||
| 1471 | : "authenticated", | ||
| 1472 | ); | ||
| 1473 | if (scenario !== "revoked" && scenario !== "invalid-storage") | ||
| 1474 | assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); | ||
| 1475 | assert.deepStrictEqual( | ||
| 1476 | result.auth.account, | ||
| 1477 | scenario === "authenticated" || scenario === "missing" | ||
| 1478 | ? Option.some("maria") | ||
| 1479 | : Option.none(), | ||
| 1480 | ); | ||
| 1481 | assert.strictEqual( | ||
| 1482 | commands.some((command) => command.startsWith("tea ")), | ||
| 1483 | scenario === "missing", | ||
| 1484 | ); | ||
| 1485 | assert.include(commands, "fj version"); | ||
| 1486 | }).pipe( | ||
| 1487 | Effect.provide( | ||
| 1488 | Layer.mergeAll( | ||
| 1489 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 1490 | getAccount: (input) => { | ||
| 1491 | assert.strictEqual(scenario, "authenticated"); | ||
| 1492 | assert.deepStrictEqual(input, { | ||
| 1493 | cwd: "/repo", | ||
| 1494 | baseUrl: "http://forgejo.local:3000", | ||
| 1495 | }); | ||
| 1496 | return Effect.succeed("maria"); | ||
| 1497 | }, | ||
| 1498 | listLogins: (input) => { | ||
| 1499 | assert.strictEqual( | ||
| 1500 | input.remoteUrl, | ||
| 1501 | "http://forgejo.local:3000/maria/project.git", | ||
| 1502 | ); | ||
| 1503 | if (scenario === "invalid-storage") | ||
| 1504 | return Effect.fail( | ||
| 1505 | new ForgejoCli.ForgejoCliError({ | ||
| 1506 | command: "fj", | ||
| 1507 | cwd: input.cwd, | ||
| 1508 | reason: "authentication", | ||
| 1509 | detail: "fj authentication storage is invalid.", | ||
| 1510 | }), | ||
| 1511 | ); | ||
| 1512 | return Effect.succeed([ | ||
| 1513 | { | ||
| 1514 | name: "forgejo.local:3000", | ||
| 1515 | url: "http://forgejo.local:3000", | ||
| 1516 | user: "", | ||
| 1517 | default: "false", | ||
| 1518 | }, | ||
| 1519 | ]); | ||
| 1520 | }, | ||
| 1521 | }), | ||
| 1522 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 1523 | run: (input) => { | ||
| 1524 | commands.push(`${input.command} ${input.args.join(" ")}`); | ||
| 1525 | if (input.command === "git") | ||
| 1526 | return Effect.succeed( | ||
| 1527 | processOutput("http://forgejo.local:3000/maria/project.git\n"), | ||
| 1528 | ); | ||
| 1529 | if (input.command === "fj") { | ||
| 1530 | if (scenario === "missing") | ||
| 1531 | return Effect.fail( | ||
| 1532 | new VcsProcessSpawnError({ | ||
| 1533 | operation: input.operation, | ||
| 1534 | command: input.command, | ||
| 1535 | cwd: input.cwd, | ||
| 1536 | cause: new Error("fj not found"), | ||
| 1537 | }), | ||
| 1538 | ); | ||
| 1539 | if (input.args[0] === "version") | ||
| 1540 | return Effect.succeed(processOutput("fj 0.10.0")); | ||
| 1541 | if (scenario === "invalid-storage") { | ||
| 1542 | assert.deepStrictEqual(input.args, ["auth", "list"]); | ||
| 1543 | return Effect.succeed(processOutput("")); | ||
| 1544 | } | ||
| 1545 | assert.deepStrictEqual(input.args, [ | ||
| 1546 | "--host", | ||
| 1547 | "http://forgejo.local:3000", | ||
| 1548 | "whoami", | ||
| 1549 | ]); | ||
| 1550 | return Effect.succeed( | ||
| 1551 | processOutput("", { | ||
| 1552 | exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), | ||
| 1553 | }), | ||
| 1554 | ); | ||
| 1555 | } | ||
| 1556 | assert.strictEqual(input.command, "tea"); | ||
| 1557 | return Effect.succeed( | ||
| 1558 | input.args[0] === "--version" | ||
| 1559 | ? processOutput("tea version 0.16.0") | ||
| 1560 | : processOutput( | ||
| 1561 | encodeJson([ | ||
| 1562 | { | ||
| 1563 | name: "work", | ||
| 1564 | url: "http://forgejo.local:3000", | ||
| 1565 | user: "maria", | ||
| 1566 | default: "true", | ||
| 1567 | valid: "true", | ||
| 1568 | }, | ||
| 1569 | ]), | ||
| 1570 | ), | ||
| 1571 | ); | ||
| 1572 | }, | ||
| 1573 | }), | ||
| 1574 | ), | ||
| 1575 | ), | ||
| 1576 | ); | ||
| 1577 | } | ||
| 1578 | }), | ||
| 1579 | ); | ||
| 1580 | |||
| 1581 | it.effect( | 339 | it.effect( |
| 1582 | "checks out fj pull refs and preserves existing branches and dirty files until forced", | 340 | "checks out fj pull refs and preserves existing branches and dirty files until forced", |
| 1583 | () => | 341 | () => |
| ... | @@ -1627,22 +385,26 @@ it.effect( | ... | @@ -1627,22 +385,26 @@ it.effect( |
| 1627 | args: ["rev-parse", "HEAD"], | 385 | args: ["rev-parse", "HEAD"], |
| 1628 | })).stdout.trim(); | 386 | })).stdout.trim(); |
| 1629 | const fetched: string[] = []; | 387 | const fetched: string[] = []; |
| 388 | const host = yield* SourceControlHost.SourceControlHost; | ||
| 1630 | const provider = yield* ForgejoSourceControlProvider.make.pipe( | 389 | const provider = yield* ForgejoSourceControlProvider.make.pipe( |
| 1631 | Effect.provideService( | 390 | Effect.provideService( |
| 1632 | VcsProcess.VcsProcess, | 391 | SourceControlHost.SourceControlHost, |
| 1633 | VcsProcess.VcsProcess.of({ | 392 | SourceControlHost.SourceControlHost.of({ |
| 1634 | run: (input) => { | 393 | settings: host.settings, |
| 1635 | if (input.args[0] !== "fetch") return git.run(input); | 394 | process: { |
| 1636 | const url = input.args[2]; | 395 | run: (input) => { |
| 1637 | assert.isDefined(url); | 396 | if (input.args[0] !== "fetch") return git.run(input); |
| 1638 | fetched.push(url!); | 397 | const url = input.args[2]; |
| 1639 | // Only SSH transport is substituted; both paths fetch the real pull ref. | 398 | assert.isDefined(url); |
| 1640 | return git.run({ | 399 | fetched.push(url!); |
| 1641 | ...input, | 400 | // Only SSH transport is substituted; both paths fetch the real pull ref. |
| 1642 | args: input.args.map((arg) => | 401 | return git.run({ |
| 1643 | arg === "git@forgejo.test:reviewer/project.git" ? source : arg, | 402 | ...input, |
| 1644 | ), | 403 | args: input.args.map((arg) => |
| 1645 | }); | 404 | arg === "git@forgejo.test:reviewer/project.git" ? source : arg, |
| 405 | ), | ||
| 406 | }); | ||
| 407 | }, | ||
| 1646 | }, | 408 | }, |
| 1647 | }), | 409 | }), |
| 1648 | ), | 410 | ), |
| ... | @@ -1759,6 +521,12 @@ it.effect( | ... | @@ -1759,6 +521,12 @@ it.effect( |
| 1759 | assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); | 521 | assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); |
| 1760 | }).pipe( | 522 | }).pipe( |
| 1761 | Effect.scoped, | 523 | Effect.scoped, |
| 1762 | Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), | 524 | Effect.provide( |
| 525 | ServerSourceControlHost.layer.pipe( | ||
| 526 | Layer.provideMerge(VcsProcess.layer), | ||
| 527 | Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), | ||
| 528 | Layer.provideMerge(NodeServices.layer), | ||
| 529 | ), | ||
| 530 | ), | ||
| 1763 | ), | 531 | ), |
| 1764 | ); | 532 | ); |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+1-1| ... | @@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ... | @@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 19 | import * as GitHubApi from "./GitHubApi.ts"; | 19 | import * as GitHubApi from "./GitHubApi.ts"; |
| 20 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | 20 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 21 | import * as GitLabCli from "./GitLabCli.ts"; | 21 | import * as GitLabCli from "./GitLabCli.ts"; |
| 22 | import * as ForgejoCli from "./ForgejoCli.ts"; | 22 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 23 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 23 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 24 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | 24 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 25 | 25 |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+11-4| ... | @@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro | ... | @@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro |
| 15 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | 15 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 16 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 16 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 17 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 17 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 18 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | 18 | import * as BuiltInDrivers from "./builtInDrivers.ts"; |
| 19 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | 19 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 20 | import { | 20 | import { |
| 21 | probeSourceControlProvider, | 21 | probeSourceControlProvider, |
| ... | @@ -308,11 +308,18 @@ export const make = Effect.gen(function* () { | ... | @@ -308,11 +308,18 @@ export const make = Effect.gen(function* () { |
| 308 | const github = yield* GitHubSourceControlProvider.make; | 308 | const github = yield* GitHubSourceControlProvider.make; |
| 309 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; | 309 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; |
| 310 | const gitlab = yield* GitLabSourceControlProvider.make; | 310 | const gitlab = yield* GitLabSourceControlProvider.make; |
| 311 | const forgejo = yield* ForgejoSourceControlProvider.make; | ||
| 312 | const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; | ||
| 313 | const bitbucket = yield* BitbucketSourceControlProvider.make; | 311 | const bitbucket = yield* BitbucketSourceControlProvider.make; |
| 314 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; | 312 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; |
| 315 | const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; | 313 | const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; |
| 314 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => | ||
| 315 | driver.make.pipe( | ||
| 316 | Effect.map((instance): SourceControlProviderRegistration => ({ | ||
| 317 | kind: driver.kind, | ||
| 318 | provider: instance.sourceControl, | ||
| 319 | discovery: instance.discovery, | ||
| 320 | })), | ||
| 321 | ), | ||
| 322 | ); | ||
| 316 | return yield* makeWithProviders([ | 323 | return yield* makeWithProviders([ |
| 317 | { | 324 | { |
| 318 | kind: "github", | 325 | kind: "github", |
| ... | @@ -334,7 +341,7 @@ export const make = Effect.gen(function* () { | ... | @@ -334,7 +341,7 @@ export const make = Effect.gen(function* () { |
| 334 | provider: bitbucket, | 341 | provider: bitbucket, |
| 335 | discovery: bitbucketDiscovery, | 342 | discovery: bitbucketDiscovery, |
| 336 | }, | 343 | }, |
| 337 | { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery }, | 344 | ...drivers, |
| 338 | ]); | 345 | ]); |
| 339 | }); | 346 | }); |
| 340 | 347 |
apps/server/src/sourceControl/builtInDrivers.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | /** | ||
| 2 | * The source control drivers this build ships with. Both registries, repository operations and | ||
| 3 | * pull requests, iterate this list; a host with no driver here shows up as unsupported. | ||
| 4 | * | ||
| 5 | * Adding a host means writing its `@t3tools/source-control-<host>` package, adding its driver | ||
| 6 | * here, and providing its services' layers in `layer` below. | ||
| 7 | * | ||
| 8 | * @module sourceControl/builtInDrivers | ||
| 9 | */ | ||
| 10 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | ||
| 11 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | |||
| 14 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | ||
| 15 | |||
| 16 | /** Ordered as the hosts appear in discovery, after the ones still built in the server. */ | ||
| 17 | export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const; | ||
| 18 | |||
| 19 | /** The services the built-in drivers' packages own, plus the host port they all run against. */ | ||
| 20 | export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer)); | ||
apps/server/src/sourceControl/forgejoPullRequests.ts deleted-47| ... | @@ -1,47 +0,0 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import type { ChangeRequest } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | const Repository = Schema.Struct({ | ||
| 6 | full_name: Schema.String, | ||
| 7 | owner: Schema.Struct({ login: Schema.String }), | ||
| 8 | }); | ||
| 9 | const Branch = Schema.Struct({ | ||
| 10 | ref: Schema.String, | ||
| 11 | sha: Schema.String, | ||
| 12 | repo: Schema.NullOr(Repository), | ||
| 13 | }); | ||
| 14 | export const ForgejoPullRequestSchema = Schema.Struct({ | ||
| 15 | number: Schema.Int, | ||
| 16 | title: Schema.String, | ||
| 17 | html_url: Schema.String, | ||
| 18 | state: Schema.String, | ||
| 19 | merged: Schema.Boolean, | ||
| 20 | draft: Schema.optional(Schema.Boolean), | ||
| 21 | base: Branch, | ||
| 22 | head: Branch, | ||
| 23 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 24 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 25 | updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 26 | }); | ||
| 27 | export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest { | ||
| 28 | return { | ||
| 29 | provider: "forgejo", | ||
| 30 | number: raw.number, | ||
| 31 | title: raw.title, | ||
| 32 | url: raw.html_url, | ||
| 33 | state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open", | ||
| 34 | isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title), | ||
| 35 | baseRefName: raw.base.ref, | ||
| 36 | headRefName: raw.head.ref, | ||
| 37 | closedAt: raw.closed_at ?? null, | ||
| 38 | mergedAt: raw.merged_at ?? null, | ||
| 39 | updatedAt: raw.updated_at ?? Option.none(), | ||
| 40 | isCrossRepository: | ||
| 41 | raw.head.repo !== null && | ||
| 42 | raw.base.repo !== null && | ||
| 43 | raw.head.repo.full_name !== raw.base.repo.full_name, | ||
| 44 | headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null, | ||
| 45 | headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null, | ||
| 46 | }; | ||
| 47 | } | ||
apps/server/src/ws.ts+2-4| ... | @@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | ... | @@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 233 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 233 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 234 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | 234 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 235 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 235 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 236 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; | 236 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 237 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 237 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 238 | import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; | ||
| 239 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 238 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 240 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | 239 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 241 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; | 240 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| ... | @@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap( | ... | @@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap( |
| 3209 | BitbucketApi.layer, | 3208 | BitbucketApi.layer, |
| 3210 | GitHubApi.layerWithDependencies, | 3209 | GitHubApi.layerWithDependencies, |
| 3211 | GitLabCli.layer, | 3210 | GitLabCli.layer, |
| 3212 | ForgejoCli.layer, | 3211 | SourceControlBuiltInDrivers.layer, |
| 3213 | ServerSourceControlHost.layer, | ||
| 3214 | ), | 3212 | ), |
| 3215 | ), | 3213 | ), |
| 3216 | Layer.provideMerge(GitVcsDriver.layer), | 3214 | Layer.provideMerge(GitVcsDriver.layer), |
package.json+1-1| ... | @@ -28,7 +28,7 @@ | ... | @@ -28,7 +28,7 @@ |
| 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | "lint": "vp lint --report-unused-disable-directives", | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", |
| 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | "test": "vp run -r test", | 34 | "test": "vp run -r test", |
packages/source-control-core/package.json+9| ... | @@ -3,10 +3,18 @@ | ... | @@ -3,10 +3,18 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./server/diffFileRevisions": { | ||
| 7 | "types": "./src/server/diffFileRevisions.ts", | ||
| 8 | "import": "./src/server/diffFileRevisions.ts" | ||
| 9 | }, | ||
| 6 | "./server/discovery": { | 10 | "./server/discovery": { |
| 7 | "types": "./src/server/discovery.ts", | 11 | "types": "./src/server/discovery.ts", |
| 8 | "import": "./src/server/discovery.ts" | 12 | "import": "./src/server/discovery.ts" |
| 9 | }, | 13 | }, |
| 14 | "./server/driver": { | ||
| 15 | "types": "./src/server/driver.ts", | ||
| 16 | "import": "./src/server/driver.ts" | ||
| 17 | }, | ||
| 10 | "./server/mergeMessage": { | 18 | "./server/mergeMessage": { |
| 11 | "types": "./src/server/mergeMessage.ts", | 19 | "types": "./src/server/mergeMessage.ts", |
| 12 | "import": "./src/server/mergeMessage.ts" | 20 | "import": "./src/server/mergeMessage.ts" |
| ... | @@ -38,6 +46,7 @@ | ... | @@ -38,6 +46,7 @@ |
| 38 | }, | 46 | }, |
| 39 | "dependencies": { | 47 | "dependencies": { |
| 40 | "@t3tools/contracts": "workspace:*", | 48 | "@t3tools/contracts": "workspace:*", |
| 49 | "@t3tools/shared": "workspace:*", | ||
| 41 | "effect": "catalog:" | 50 | "effect": "catalog:" |
| 42 | }, | 51 | }, |
| 43 | "devDependencies": { | 52 | "devDependencies": { |
packages/source-control-core/src/server/diffFileRevisions.test.ts created+280| ... | @@ -0,0 +1,280 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { parseDiffFileRevisions } from "./diffFileRevisions.ts"; | ||
| 4 | |||
| 5 | function patchOf(...lines: ReadonlyArray<string>): string { | ||
| 6 | return `${lines.join("\n")}\n`; | ||
| 7 | } | ||
| 8 | |||
| 9 | describe("parseDiffFileRevisions", () => { | ||
| 10 | it("reads the head id of a changed file off its index line", () => { | ||
| 11 | const revisions = parseDiffFileRevisions( | ||
| 12 | patchOf( | ||
| 13 | "diff --git a/src/a.ts b/src/a.ts", | ||
| 14 | "index 7f2aa0ab6..b4a2a7c9a 100644", | ||
| 15 | "--- a/src/a.ts", | ||
| 16 | "+++ b/src/a.ts", | ||
| 17 | "@@ -1 +1 @@", | ||
| 18 | "-a", | ||
| 19 | "+b", | ||
| 20 | ), | ||
| 21 | ); | ||
| 22 | |||
| 23 | assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("names a deletion by the path it had, which is the one still on screen", () => { | ||
| 27 | const revisions = parseDiffFileRevisions( | ||
| 28 | patchOf( | ||
| 29 | "diff --git a/gone.ts b/gone.ts", | ||
| 30 | "deleted file mode 100644", | ||
| 31 | "index 1111111..0000000", | ||
| 32 | "--- a/gone.ts", | ||
| 33 | "+++ /dev/null", | ||
| 34 | "@@ -1 +0,0 @@", | ||
| 35 | "-x", | ||
| 36 | ), | ||
| 37 | ); | ||
| 38 | |||
| 39 | assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("names a rename by where it moved to", () => { | ||
| 43 | const revisions = parseDiffFileRevisions( | ||
| 44 | patchOf( | ||
| 45 | "diff --git a/old.ts b/new.ts", | ||
| 46 | "similarity index 90%", | ||
| 47 | "rename from old.ts", | ||
| 48 | "rename to new.ts", | ||
| 49 | "index 2222222..3333333 100644", | ||
| 50 | "--- a/old.ts", | ||
| 51 | "+++ b/new.ts", | ||
| 52 | "@@ -1 +1 @@", | ||
| 53 | "-a", | ||
| 54 | "+b", | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | |||
| 58 | assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("names a rename that changed nothing, which states no paths of its own", () => { | ||
| 62 | const revisions = parseDiffFileRevisions( | ||
| 63 | patchOf( | ||
| 64 | "diff --git a/old.ts b/new.ts", | ||
| 65 | "similarity index 100%", | ||
| 66 | "rename from old.ts", | ||
| 67 | "rename to new.ts", | ||
| 68 | "index 2222222..2222222 100644", | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | |||
| 72 | assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("leaves out a file Bitbucket excluded, which it gives no index line for", () => { | ||
| 76 | const revisions = parseDiffFileRevisions( | ||
| 77 | patchOf( | ||
| 78 | "diff --git a/package.json b/package.json", | ||
| 79 | "index 7f2aa0ab6..b4a2a7c9a 100644", | ||
| 80 | "--- a/package.json", | ||
| 81 | "+++ b/package.json", | ||
| 82 | "@@ -1 +1 @@", | ||
| 83 | '- "x": "1"', | ||
| 84 | '+ "x": "2"', | ||
| 85 | "diff --git a/yarn.lock b/yarn.lock", | ||
| 86 | 'File excluded by pattern "yarn.lock"', | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | |||
| 90 | assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("stops reading headers at the first hunk, so content cannot pose as one", () => { | ||
| 94 | const revisions = parseDiffFileRevisions( | ||
| 95 | patchOf( | ||
| 96 | "diff --git a/notes.md b/notes.md", | ||
| 97 | "index aaaaaaa..bbbbbbb 100644", | ||
| 98 | "--- a/notes.md", | ||
| 99 | "+++ b/notes.md", | ||
| 100 | "@@ -1,2 +1,2 @@", | ||
| 101 | "--- a/decoy.ts", | ||
| 102 | "+++ b/decoy.ts", | ||
| 103 | "+index ccccccc..ddddddd 100644", | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | |||
| 107 | assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("splits a header whose paths contain the separator, by the sides agreeing", () => { | ||
| 111 | const revisions = parseDiffFileRevisions( | ||
| 112 | patchOf( | ||
| 113 | "diff --git a/one b/two.ts b/one b/two.ts", | ||
| 114 | "index eeeeeee..fffffff 100644", | ||
| 115 | "@@ -1 +1 @@", | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | |||
| 119 | assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("leaves out an added file that Bitbucket sent no index line for", () => { | ||
| 123 | const revisions = parseDiffFileRevisions( | ||
| 124 | patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"), | ||
| 125 | ); | ||
| 126 | |||
| 127 | assert.strictEqual(revisions.size, 0); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("reads nothing out of an empty patch", () => { | ||
| 131 | assert.strictEqual(parseDiffFileRevisions("").size, 0); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("reads a name git had to quote, here one holding a tab", () => { | ||
| 135 | const revisions = parseDiffFileRevisions( | ||
| 136 | patchOf( | ||
| 137 | 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"', | ||
| 138 | "index 4444444..5555555 100644", | ||
| 139 | '--- "a/we\\tird.ts"', | ||
| 140 | '+++ "b/we\\tird.ts"', | ||
| 141 | "@@ -1 +1 @@", | ||
| 142 | "-a", | ||
| 143 | "+b", | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("rejoins the octal bytes git writes for a name outside ASCII", () => { | ||
| 151 | const revisions = parseDiffFileRevisions( | ||
| 152 | patchOf( | ||
| 153 | 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"', | ||
| 154 | "index 6666666..7777777 100644", | ||
| 155 | "@@ -1 +1 @@", | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | |||
| 159 | assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("splits a rename header where git quoted only the side that needed it", () => { | ||
| 163 | const revisions = parseDiffFileRevisions( | ||
| 164 | patchOf( | ||
| 165 | 'diff --git a/old.ts "b/new\\tname.ts"', | ||
| 166 | "similarity index 90%", | ||
| 167 | "rename from old.ts", | ||
| 168 | 'rename to "new\\tname.ts"', | ||
| 169 | "index 8888888..9999999 100644", | ||
| 170 | "--- a/old.ts", | ||
| 171 | '+++ "b/new\\tname.ts"', | ||
| 172 | "@@ -1 +1 @@", | ||
| 173 | "-a", | ||
| 174 | "+b", | ||
| 175 | ), | ||
| 176 | ); | ||
| 177 | |||
| 178 | assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("names a quoted rename that changed nothing, which states no paths of its own", () => { | ||
| 182 | const revisions = parseDiffFileRevisions( | ||
| 183 | patchOf( | ||
| 184 | 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', | ||
| 185 | "similarity index 100%", | ||
| 186 | 'rename from "old\\tname.ts"', | ||
| 187 | 'rename to "new\\tname.ts"', | ||
| 188 | "index abcabca..abcabca 100644", | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 192 | assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("keeps a character from outside the basic plane that git left unescaped", () => { | ||
| 196 | // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header | ||
| 197 | // for the tab. Encoding what it left one unit at a time would split the pair into two halves. | ||
| 198 | const revisions = parseDiffFileRevisions( | ||
| 199 | patchOf( | ||
| 200 | 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"', | ||
| 201 | "index aaaaaaa..bbbbbbb 100644", | ||
| 202 | "@@ -1 +1 @@", | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("drops the tab git ends a name holding a space with", () => { | ||
| 210 | const revisions = parseDiffFileRevisions( | ||
| 211 | patchOf( | ||
| 212 | "diff --git a/with space.txt b/with space.txt", | ||
| 213 | "index 1111111..6178079 100644", | ||
| 214 | "--- a/with space.txt\t", | ||
| 215 | "+++ b/with space.txt\t", | ||
| 216 | "@@ -1 +1 @@", | ||
| 217 | "-a", | ||
| 218 | "+b", | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | |||
| 222 | assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("drops that tab from a quoted name too, where it lands past the closing quote", () => { | ||
| 226 | const revisions = parseDiffFileRevisions( | ||
| 227 | patchOf( | ||
| 228 | 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"', | ||
| 229 | "index 2222222..7777777 100644", | ||
| 230 | '--- "a/we ird\\tname.ts"\t', | ||
| 231 | '+++ "b/we ird\\tname.ts"\t', | ||
| 232 | "@@ -1 +1 @@", | ||
| 233 | "-a", | ||
| 234 | "+b", | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | |||
| 238 | assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("reads a deletion whose name holds a space, tab and all", () => { | ||
| 242 | const revisions = parseDiffFileRevisions( | ||
| 243 | patchOf( | ||
| 244 | "diff --git a/with space.txt b/with space.txt", | ||
| 245 | "deleted file mode 100644", | ||
| 246 | "index 3333333..0000000", | ||
| 247 | "--- a/with space.txt\t", | ||
| 248 | "+++ /dev/null", | ||
| 249 | "@@ -1 +0,0 @@", | ||
| 250 | "-a", | ||
| 251 | ), | ||
| 252 | ); | ||
| 253 | |||
| 254 | assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]); | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("drops the timestamp other producers of the format write past that tab", () => { | ||
| 258 | const revisions = parseDiffFileRevisions( | ||
| 259 | patchOf( | ||
| 260 | "diff --git a/stamped.ts b/stamped.ts", | ||
| 261 | "index 4444444..5555555 100644", | ||
| 262 | "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000", | ||
| 263 | "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000", | ||
| 264 | "@@ -1 +1 @@", | ||
| 265 | "-a", | ||
| 266 | "+b", | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | |||
| 270 | assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]); | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("splits an unquoted header whose names hold a space, by the sides agreeing", () => { | ||
| 274 | const revisions = parseDiffFileRevisions( | ||
| 275 | patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"), | ||
| 276 | ); | ||
| 277 | |||
| 278 | assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]); | ||
| 279 | }); | ||
| 280 | }); | ||
packages/source-control-core/src/server/diffFileRevisions.ts created+129| ... | @@ -0,0 +1,129 @@ | ||
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | ||
| 2 | |||
| 3 | const ENTRY = "diff --git "; | ||
| 4 | const QUOTE = '"'; | ||
| 5 | |||
| 6 | interface Entry { | ||
| 7 | oldPath: string | null; | ||
| 8 | newPath: string | null; | ||
| 9 | deleted: boolean; | ||
| 10 | revision: string | null; | ||
| 11 | /** Past the first hunk header every line is content, and content can start like a header. */ | ||
| 12 | inBody: boolean; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** Where a quoted name closes, given git escapes every quote the name itself holds. */ | ||
| 16 | function quotedEnd(rest: string): number { | ||
| 17 | for (let at = 1; at < rest.length; at += 1) { | ||
| 18 | const char = rest.charAt(at); | ||
| 19 | if (char === "\\") { | ||
| 20 | at += 1; | ||
| 21 | continue; | ||
| 22 | } | ||
| 23 | if (char === QUOTE) return at; | ||
| 24 | } | ||
| 25 | return -1; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** | ||
| 29 | * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the | ||
| 30 | * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with | ||
| 31 | * that tab escaped, so the first literal tab is never part of what the file is called. | ||
| 32 | */ | ||
| 33 | function sidePath(rest: string, prefix: string): string | null { | ||
| 34 | const tab = rest.indexOf("\t"); | ||
| 35 | const token = tab === -1 ? rest : rest.slice(0, tab); | ||
| 36 | if (token === "/dev/null") return null; | ||
| 37 | const path = unquoteGitPatchPath(token); | ||
| 38 | return path.startsWith(prefix) ? path.slice(prefix.length) : path; | ||
| 39 | } | ||
| 40 | |||
| 41 | function headerSide(token: string, prefix: string): string | null { | ||
| 42 | const path = unquoteGitPatchPath(token); | ||
| 43 | return path.startsWith(prefix) ? path.slice(prefix.length) : null; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** | ||
| 47 | * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one | ||
| 48 | * two` can split in more than one place, so the split leaving both sides equal wins; a rename | ||
| 49 | * (the only case where sides differ) states its names on separate lines instead. A quoted name | ||
| 50 | * ends at its own closing quote and needs none of that guessing. | ||
| 51 | */ | ||
| 52 | function headerPaths(rest: string): readonly [string | null, string | null] { | ||
| 53 | if (rest.startsWith(QUOTE)) { | ||
| 54 | const end = quotedEnd(rest); | ||
| 55 | if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null]; | ||
| 56 | return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")]; | ||
| 57 | } | ||
| 58 | if (rest.endsWith(QUOTE)) { | ||
| 59 | const opens = rest.indexOf(QUOTE); | ||
| 60 | if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null]; | ||
| 61 | return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")]; | ||
| 62 | } | ||
| 63 | if (!rest.startsWith("a/")) return [null, null]; | ||
| 64 | const splits: Array<number> = []; | ||
| 65 | for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at); | ||
| 66 | const chosen = | ||
| 67 | splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ?? | ||
| 68 | (splits.length === 1 ? splits[0] : undefined); | ||
| 69 | return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)]; | ||
| 70 | } | ||
| 71 | |||
| 72 | /** The right-hand id of `index <before>..<after> <mode>`. */ | ||
| 73 | function headRevision(rest: string): string | null { | ||
| 74 | const gap = rest.indexOf(".."); | ||
| 75 | if (gap === -1) return null; | ||
| 76 | const after = rest.slice(gap + 2); | ||
| 77 | const end = after.indexOf(" "); | ||
| 78 | const head = end === -1 ? after : after.slice(0, end); | ||
| 79 | return head.length === 0 ? null : head; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** | ||
| 83 | * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>` | ||
| 84 | * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except | ||
| 85 | * for a deletion where only the old name exists. An entry with no `index` line (most often one | ||
| 86 | * Bitbucket excluded by pattern) is left out. | ||
| 87 | */ | ||
| 88 | export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> { | ||
| 89 | const revisions = new Map<string, string>(); | ||
| 90 | let entry: Entry | null = null; | ||
| 91 | |||
| 92 | const close = () => { | ||
| 93 | if (entry === null) return; | ||
| 94 | const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath); | ||
| 95 | if (path !== null && path.length > 0 && entry.revision !== null) { | ||
| 96 | revisions.set(path, entry.revision); | ||
| 97 | } | ||
| 98 | entry = null; | ||
| 99 | }; | ||
| 100 | |||
| 101 | for (const line of patch.split("\n")) { | ||
| 102 | if (line.startsWith(ENTRY)) { | ||
| 103 | close(); | ||
| 104 | const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length)); | ||
| 105 | entry = { oldPath, newPath, deleted: false, revision: null, inBody: false }; | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | if (entry === null || entry.inBody) continue; | ||
| 109 | if (line.startsWith("@@")) { | ||
| 110 | entry.inBody = true; | ||
| 111 | } else if (line.startsWith("index ")) { | ||
| 112 | entry.revision = headRevision(line.slice("index ".length)); | ||
| 113 | } else if (line.startsWith("deleted file mode")) { | ||
| 114 | entry.deleted = true; | ||
| 115 | } else if (line.startsWith("rename from ")) { | ||
| 116 | entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length)); | ||
| 117 | } else if (line.startsWith("rename to ")) { | ||
| 118 | entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length)); | ||
| 119 | } else if (line.startsWith("--- ")) { | ||
| 120 | entry.oldPath = sidePath(line.slice(4), "a/"); | ||
| 121 | } else if (line.startsWith("+++ ")) { | ||
| 122 | const side = sidePath(line.slice(4), "b/"); | ||
| 123 | entry.newPath = side; | ||
| 124 | if (side === null) entry.deleted = true; | ||
| 125 | } | ||
| 126 | } | ||
| 127 | close(); | ||
| 128 | return revisions; | ||
| 129 | } | ||
packages/source-control-core/src/server/driver.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | /** | ||
| 2 | * SourceControlDriver — what a source control provider package hands the server. | ||
| 3 | * | ||
| 4 | * One driver per host kind. The server lists its drivers once and both registries (repository | ||
| 5 | * operations and pull requests) iterate that list, so adding a host means writing its package | ||
| 6 | * and adding its driver to the list. | ||
| 7 | * | ||
| 8 | * `make` yields what it needs from the environment: the `SourceControlHost` port, Effect | ||
| 9 | * platform services, and the package's own services, which the server provides from the | ||
| 10 | * package's layers. Its `R` channel is the full list. | ||
| 11 | * | ||
| 12 | * @module source-control-core/server/driver | ||
| 13 | */ | ||
| 14 | import type { SourceControlProviderKind } from "@t3tools/contracts"; | ||
| 15 | import type * as Effect from "effect/Effect"; | ||
| 16 | |||
| 17 | import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; | ||
| 18 | import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; | ||
| 19 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 20 | |||
| 21 | export interface SourceControlDriverInstance { | ||
| 22 | readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"]; | ||
| 23 | readonly discovery: SourceControlProviderDiscoverySpec; | ||
| 24 | /** Null for a host whose pull requests this build cannot read. */ | ||
| 25 | readonly pullRequests: PullRequestProviderApi | null; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface SourceControlDriver<R = never> { | ||
| 29 | readonly kind: SourceControlProviderKind; | ||
| 30 | readonly make: Effect.Effect<SourceControlDriverInstance, never, R>; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** Declares a driver with its `R` inferred from `make`. */ | ||
| 34 | export const defineSourceControlDriver = <R>( | ||
| 35 | driver: SourceControlDriver<R>, | ||
| 36 | ): SourceControlDriver<R> => driver; | ||
packages/source-control-forgejo/package.json created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/source-control-forgejo", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | "./server/driver": { | ||
| 7 | "types": "./src/server/driver.ts", | ||
| 8 | "import": "./src/server/driver.ts" | ||
| 9 | }, | ||
| 10 | "./server/ForgejoCli": { | ||
| 11 | "types": "./src/server/ForgejoCli.ts", | ||
| 12 | "import": "./src/server/ForgejoCli.ts" | ||
| 13 | }, | ||
| 14 | "./server/forgejoPullRequestJson": { | ||
| 15 | "types": "./src/server/forgejoPullRequestJson.ts", | ||
| 16 | "import": "./src/server/forgejoPullRequestJson.ts" | ||
| 17 | }, | ||
| 18 | "./server/ForgejoPullRequestProvider": { | ||
| 19 | "types": "./src/server/ForgejoPullRequestProvider.ts", | ||
| 20 | "import": "./src/server/ForgejoPullRequestProvider.ts" | ||
| 21 | }, | ||
| 22 | "./server/forgejoPullRequests": { | ||
| 23 | "types": "./src/server/forgejoPullRequests.ts", | ||
| 24 | "import": "./src/server/forgejoPullRequests.ts" | ||
| 25 | }, | ||
| 26 | "./server/ForgejoSourceControlProvider": { | ||
| 27 | "types": "./src/server/ForgejoSourceControlProvider.ts", | ||
| 28 | "import": "./src/server/ForgejoSourceControlProvider.ts" | ||
| 29 | } | ||
| 30 | }, | ||
| 31 | "scripts": { | ||
| 32 | "typecheck": "tsc --noEmit", | ||
| 33 | "test": "vp test run --config ../../vite.config.ts --dir ." | ||
| 34 | }, | ||
| 35 | "dependencies": { | ||
| 36 | "@t3tools/contracts": "workspace:*", | ||
| 37 | "@t3tools/provider-core": "workspace:*", | ||
| 38 | "@t3tools/shared": "workspace:*", | ||
| 39 | "@t3tools/source-control-core": "workspace:*", | ||
| 40 | "effect": "catalog:" | ||
| 41 | }, | ||
| 42 | "devDependencies": { | ||
| 43 | "@effect/vitest": "catalog:", | ||
| 44 | "@t3tools/source-control-testing": "workspace:*", | ||
| 45 | "@types/node": "catalog:", | ||
| 46 | "vite-plus": "catalog:" | ||
| 47 | } | ||
| 48 | } | ||
packages/source-control-forgejo/src/server/ForgejoCli.ts created+724| ... | @@ -0,0 +1,724 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Semaphore from "effect/Semaphore"; | ||
| 9 | import * as NodeOS from "node:os"; | ||
| 10 | // @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. | ||
| 11 | import * as NodePath from "node:path"; | ||
| 12 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; | ||
| 13 | import { ChildProcessSpawner } from "effect/process"; | ||
| 14 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 17 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | ||
| 18 | import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 19 | |||
| 20 | const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 21 | |||
| 22 | export class ForgejoCliError extends Schema.TaggedError<ForgejoCliError>()("ForgejoCliError", { | ||
| 23 | command: Schema.Literals(["fj", "tea"]), | ||
| 24 | cwd: Schema.String, | ||
| 25 | detail: Schema.String, | ||
| 26 | reason: Schema.optional( | ||
| 27 | Schema.Literals([ | ||
| 28 | "missing-cli", | ||
| 29 | "authentication", | ||
| 30 | "forbidden", | ||
| 31 | "not-found", | ||
| 32 | "rate-limit", | ||
| 33 | "invalid-response", | ||
| 34 | ]), | ||
| 35 | ), | ||
| 36 | httpStatus: Schema.optional(Schema.Int), | ||
| 37 | cause: Schema.optional(Schema.Defect()), | ||
| 38 | }) {} | ||
| 39 | |||
| 40 | export const ForgejoLoginSchema = Schema.Struct({ | ||
| 41 | name: Schema.String, | ||
| 42 | url: Schema.String, | ||
| 43 | ssh_host: Schema.optional(Schema.String), | ||
| 44 | valid: Schema.optional(Schema.String), | ||
| 45 | user: Schema.String, | ||
| 46 | default: Schema.String, | ||
| 47 | }); | ||
| 48 | |||
| 49 | export function parseForgejoLogins(raw: string) { | ||
| 50 | const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw); | ||
| 51 | return Result.isSuccess(decoded) ? decoded.success : []; | ||
| 52 | } | ||
| 53 | |||
| 54 | export const ForgejoKeysSchema = Schema.Struct({ | ||
| 55 | hosts: Schema.Record( | ||
| 56 | Schema.String, | ||
| 57 | Schema.Struct({ | ||
| 58 | type: Schema.Literals(["Application", "OAuth"]), | ||
| 59 | token: Schema.String, | ||
| 60 | }), | ||
| 61 | ), | ||
| 62 | aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema); | ||
| 66 | |||
| 67 | /** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */ | ||
| 68 | function forgejoKeysPaths(input: { | ||
| 69 | readonly platform: string; | ||
| 70 | readonly home: string; | ||
| 71 | readonly dataHome?: string; | ||
| 72 | readonly appData?: string; | ||
| 73 | }) { | ||
| 74 | if (input.platform === "darwin") | ||
| 75 | return ["forgejo-cli", "Cyborus"].map((organization) => | ||
| 76 | NodePath.join( | ||
| 77 | input.home, | ||
| 78 | "Library", | ||
| 79 | "Application Support", | ||
| 80 | `${organization}.forgejo-cli`, | ||
| 81 | "keys.json", | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | if (input.platform === "win32") | ||
| 85 | return ["forgejo-cli", "Cyborus"].map((organization) => | ||
| 86 | NodePath.win32.join( | ||
| 87 | input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"), | ||
| 88 | organization, | ||
| 89 | "forgejo-cli", | ||
| 90 | "data", | ||
| 91 | "keys.json", | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | return [ | ||
| 95 | NodePath.join( | ||
| 96 | input.dataHome && NodePath.isAbsolute(input.dataHome) | ||
| 97 | ? input.dataHome | ||
| 98 | : NodePath.join(input.home, ".local", "share"), | ||
| 99 | "forgejo-cli", | ||
| 100 | "keys.json", | ||
| 101 | ), | ||
| 102 | ]; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface ForgejoRepositoryInput { | ||
| 106 | readonly cwd: string; | ||
| 107 | readonly context?: SourceControlProviderContext; | ||
| 108 | readonly repository?: string; | ||
| 109 | readonly reference?: string; | ||
| 110 | readonly host?: string; | ||
| 111 | } | ||
| 112 | |||
| 113 | export interface ForgejoRepository { | ||
| 114 | readonly command?: "fj" | "tea"; | ||
| 115 | readonly login: string; | ||
| 116 | readonly repository: string; | ||
| 117 | readonly baseUrl: string; | ||
| 118 | } | ||
| 119 | |||
| 120 | export interface ForgejoApiInput extends ForgejoRepositoryInput { | ||
| 121 | readonly path: string; | ||
| 122 | readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | ||
| 123 | readonly body?: unknown; | ||
| 124 | } | ||
| 125 | |||
| 126 | export class ForgejoCli extends Context.Service< | ||
| 127 | ForgejoCli, | ||
| 128 | { | ||
| 129 | readonly execute: (input: { | ||
| 130 | readonly command?: "fj" | "tea"; | ||
| 131 | readonly cwd: string; | ||
| 132 | readonly args: ReadonlyArray<string>; | ||
| 133 | readonly stdin?: string; | ||
| 134 | readonly timeoutMs?: number; | ||
| 135 | readonly maxOutputBytes?: number; | ||
| 136 | }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>; | ||
| 137 | readonly listLogins?: (input: { | ||
| 138 | readonly cwd: string; | ||
| 139 | readonly command: "fj" | "tea"; | ||
| 140 | readonly remoteUrl?: string; | ||
| 141 | }) => Effect.Effect<ReturnType<typeof parseForgejoLogins>, ForgejoCliError>; | ||
| 142 | readonly getAccount?: (input: { | ||
| 143 | readonly cwd: string; | ||
| 144 | readonly baseUrl: string; | ||
| 145 | }) => Effect.Effect<string, ForgejoCliError>; | ||
| 146 | readonly resolveRepository: ( | ||
| 147 | input: ForgejoRepositoryInput, | ||
| 148 | ) => Effect.Effect<ForgejoRepository, ForgejoCliError>; | ||
| 149 | readonly api: ( | ||
| 150 | input: ForgejoApiInput, | ||
| 151 | ) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>; | ||
| 152 | } | ||
| 153 | >()("@t3tools/source-control-forgejo/server/ForgejoCli") {} | ||
| 154 | |||
| 155 | export function parseForgejoRemote(value: string) { | ||
| 156 | if (/^(?:https?|ssh):\/\//i.test(value)) { | ||
| 157 | try { | ||
| 158 | const url = new URL(value); | ||
| 159 | return { | ||
| 160 | host: url.host.toLowerCase(), | ||
| 161 | hostname: url.hostname.toLowerCase(), | ||
| 162 | ssh: url.protocol === "ssh:", | ||
| 163 | path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""), | ||
| 164 | }; | ||
| 165 | } catch { | ||
| 166 | return null; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | // SCP remotes may omit the username; URL treats these as a custom scheme. | ||
| 170 | const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value); | ||
| 171 | return ssh?.[1] && ssh[2] | ||
| 172 | ? { | ||
| 173 | host: ssh[1].toLowerCase(), | ||
| 174 | hostname: ssh[1].toLowerCase(), | ||
| 175 | ssh: true, | ||
| 176 | path: ssh[2].replace(/\.git$/, ""), | ||
| 177 | } | ||
| 178 | : null; | ||
| 179 | } | ||
| 180 | |||
| 181 | export function matchForgejoLogin( | ||
| 182 | logins: ReturnType<typeof parseForgejoLogins>, | ||
| 183 | remote: NonNullable<ReturnType<typeof parseForgejoRemote>>, | ||
| 184 | requestedHost?: string, | ||
| 185 | hostOnly = false, | ||
| 186 | ) { | ||
| 187 | const matches = [ | ||
| 188 | ...new Map( | ||
| 189 | logins | ||
| 190 | .filter((login) => { | ||
| 191 | const url = parseForgejoRemote(login.url); | ||
| 192 | if (!url) return false; | ||
| 193 | if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false; | ||
| 194 | return remote.ssh | ||
| 195 | ? login.ssh_host?.toLowerCase() === remote.host || | ||
| 196 | login.ssh_host?.toLowerCase() === remote.hostname || | ||
| 197 | url.hostname === remote.hostname | ||
| 198 | : url.host === remote.host && | ||
| 199 | ((hostOnly && !remote.path) || | ||
| 200 | !url.path || | ||
| 201 | remote.path === url.path || | ||
| 202 | remote.path.startsWith(`${url.path}/`)); | ||
| 203 | }) | ||
| 204 | .map((login) => [login.name, login]), | ||
| 205 | ).values(), | ||
| 206 | ]; | ||
| 207 | return matches.length === 1 | ||
| 208 | ? matches[0] | ||
| 209 | : new Set(matches.map((login) => login.url)).size === 1 | ||
| 210 | ? matches.find((login) => login.default === "true") | ||
| 211 | : undefined; | ||
| 212 | } | ||
| 213 | |||
| 214 | export const make = Effect.gen(function* () { | ||
| 215 | const { process } = yield* SourceControlHost.SourceControlHost; | ||
| 216 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 217 | const httpClient = yield* HttpClient.HttpClient; | ||
| 218 | const authLock = yield* Semaphore.make(1); | ||
| 219 | const authenticated = new Map<string, { token: string; time: number }>(); | ||
| 220 | const execute: ForgejoCli["Service"]["execute"] = (input) => | ||
| 221 | process | ||
| 222 | .run({ | ||
| 223 | ...input, | ||
| 224 | operation: "ForgejoCli.execute", | ||
| 225 | command: input.command ?? "tea", | ||
| 226 | timeoutMs: input.timeoutMs ?? 30_000, | ||
| 227 | }) | ||
| 228 | .pipe( | ||
| 229 | Effect.mapError( | ||
| 230 | (cause) => | ||
| 231 | new ForgejoCliError({ | ||
| 232 | command: input.command ?? "tea", | ||
| 233 | cwd: input.cwd, | ||
| 234 | ...(input.command === "fj" ? {} : { cause }), | ||
| 235 | ...(cause._tag === "VcsProcessSpawnError" | ||
| 236 | ? { reason: "missing-cli" as const } | ||
| 237 | : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" | ||
| 238 | ? { reason: "authentication" as const } | ||
| 239 | : {}), | ||
| 240 | detail: | ||
| 241 | cause._tag === "VcsProcessSpawnError" | ||
| 242 | ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry." | ||
| 243 | : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" | ||
| 244 | ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`." | ||
| 245 | : "Forgejo CLI command failed.", | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | ); | ||
| 249 | |||
| 250 | const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { | ||
| 251 | for (const path of forgejoKeysPaths({ | ||
| 252 | platform: yield* HostProcessPlatform, | ||
| 253 | home: NodeOS.homedir(), | ||
| 254 | ...(globalThis.process.env.XDG_DATA_HOME | ||
| 255 | ? { dataHome: globalThis.process.env.XDG_DATA_HOME } | ||
| 256 | : {}), | ||
| 257 | ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}), | ||
| 258 | })) { | ||
| 259 | const exists = yield* fileSystem.exists(path).pipe( | ||
| 260 | Effect.mapError( | ||
| 261 | () => | ||
| 262 | new ForgejoCliError({ | ||
| 263 | command: "fj", | ||
| 264 | cwd, | ||
| 265 | reason: "authentication", | ||
| 266 | detail: "Could not read fj authentication storage.", | ||
| 267 | }), | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | if (!exists) continue; | ||
| 271 | const raw = yield* fileSystem.readFileString(path).pipe( | ||
| 272 | Effect.mapError( | ||
| 273 | () => | ||
| 274 | new ForgejoCliError({ | ||
| 275 | command: "fj", | ||
| 276 | cwd, | ||
| 277 | reason: "authentication", | ||
| 278 | detail: "Could not read fj authentication storage.", | ||
| 279 | }), | ||
| 280 | ), | ||
| 281 | ); | ||
| 282 | const decoded = parseForgejoKeys(raw); | ||
| 283 | if (Result.isFailure(decoded)) | ||
| 284 | return yield* new ForgejoCliError({ | ||
| 285 | command: "fj", | ||
| 286 | cwd, | ||
| 287 | reason: "authentication", | ||
| 288 | detail: "fj authentication storage is invalid. Authenticate again with fj.", | ||
| 289 | }); | ||
| 290 | return decoded.success; | ||
| 291 | } | ||
| 292 | const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} }; | ||
| 293 | return empty; | ||
| 294 | }); | ||
| 295 | |||
| 296 | const publicLogins = ( | ||
| 297 | keys: typeof ForgejoKeysSchema.Type, | ||
| 298 | remoteUrl?: string, | ||
| 299 | ): ReturnType<typeof parseForgejoLogins> => { | ||
| 300 | const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; | ||
| 301 | return Object.keys(keys.hosts).flatMap((host) => { | ||
| 302 | const url = parseForgejoRemote(`https://${host}`); | ||
| 303 | // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them. | ||
| 304 | if (!url || url.path) return []; | ||
| 305 | // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP. | ||
| 306 | const scheme = | ||
| 307 | remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "") | ||
| 308 | ? "http" | ||
| 309 | : "https"; | ||
| 310 | const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" }; | ||
| 311 | const aliases = Object.entries(keys.aliases ?? {}) | ||
| 312 | .filter(([, target]) => target === host) | ||
| 313 | .map(([alias]) => ({ ...login, ssh_host: alias })); | ||
| 314 | return aliases.length ? aliases : [login]; | ||
| 315 | }); | ||
| 316 | }; | ||
| 317 | |||
| 318 | const listLogins: NonNullable<ForgejoCli["Service"]["listLogins"]> = Effect.fn( | ||
| 319 | "ForgejoCli.listLogins", | ||
| 320 | )(function* (input) { | ||
| 321 | if (input.command === "fj") { | ||
| 322 | const keys = yield* readKeys(input.cwd).pipe(Effect.result); | ||
| 323 | if (Result.isFailure(keys)) { | ||
| 324 | // Stale credentials from an uninstalled fj must not disable an available tea login. | ||
| 325 | const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( | ||
| 326 | Effect.result, | ||
| 327 | ); | ||
| 328 | if (Result.isFailure(available) && available.failure.reason === "missing-cli") return []; | ||
| 329 | return yield* keys.failure; | ||
| 330 | } | ||
| 331 | return publicLogins(keys.success, input.remoteUrl); | ||
| 332 | } | ||
| 333 | return parseForgejoLogins( | ||
| 334 | (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout, | ||
| 335 | ); | ||
| 336 | }); | ||
| 337 | |||
| 338 | const requestFj = Effect.fn("ForgejoCli.requestFj")( | ||
| 339 | function* (input: { | ||
| 340 | readonly cwd: string; | ||
| 341 | readonly baseUrl: string; | ||
| 342 | readonly token: string; | ||
| 343 | readonly path: string; | ||
| 344 | readonly method?: ForgejoApiInput["method"]; | ||
| 345 | readonly body?: string; | ||
| 346 | }) { | ||
| 347 | const base = new URL(`${input.baseUrl}/api/v1/`); | ||
| 348 | const url = new URL(input.path, base); | ||
| 349 | if ( | ||
| 350 | url.origin !== base.origin || | ||
| 351 | !url.pathname.startsWith(base.pathname) || | ||
| 352 | url.username || | ||
| 353 | url.password | ||
| 354 | ) | ||
| 355 | return yield* new ForgejoCliError({ | ||
| 356 | command: "fj", | ||
| 357 | cwd: input.cwd, | ||
| 358 | detail: "Invalid Forgejo API path.", | ||
| 359 | }); | ||
| 360 | let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe( | ||
| 361 | HttpClientRequest.setHeader("Authorization", `token ${input.token}`), | ||
| 362 | ); | ||
| 363 | if (input.body !== undefined) | ||
| 364 | request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); | ||
| 365 | const response = yield* httpClient | ||
| 366 | .execute(request) | ||
| 367 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); | ||
| 368 | const status = response.status; | ||
| 369 | const body = | ||
| 370 | status === 204 || status === 205 | ||
| 371 | ? { text: "", truncated: false, invalidUtf8: false } | ||
| 372 | : yield* collectUint8StreamText({ | ||
| 373 | stream: response.stream, | ||
| 374 | maxBytes: 8 * 1024 * 1024, | ||
| 375 | }); | ||
| 376 | if (body.truncated || body.invalidUtf8) | ||
| 377 | return yield* new ForgejoCliError({ | ||
| 378 | command: "fj", | ||
| 379 | cwd: input.cwd, | ||
| 380 | reason: "invalid-response", | ||
| 381 | detail: "Forgejo returned an oversized or invalid response.", | ||
| 382 | }); | ||
| 383 | if (status < 200 || status >= 300) { | ||
| 384 | const detail = | ||
| 385 | status === 404 | ||
| 386 | ? "Forgejo repository or pull request was not found." | ||
| 387 | : body.text | ||
| 388 | ? `Forgejo API request failed (HTTP ${status}): ${body.text}` | ||
| 389 | : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`; | ||
| 390 | return yield* new ForgejoCliError({ | ||
| 391 | command: "fj", | ||
| 392 | cwd: input.cwd, | ||
| 393 | httpStatus: status, | ||
| 394 | ...(status === 401 | ||
| 395 | ? { reason: "authentication" as const } | ||
| 396 | : status === 403 | ||
| 397 | ? { reason: "forbidden" as const } | ||
| 398 | : status === 404 | ||
| 399 | ? { reason: "not-found" as const } | ||
| 400 | : status === 429 | ||
| 401 | ? { reason: "rate-limit" as const } | ||
| 402 | : {}), | ||
| 403 | detail, | ||
| 404 | }); | ||
| 405 | } | ||
| 406 | return { | ||
| 407 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 408 | stdout: body.text, | ||
| 409 | stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`, | ||
| 410 | stdoutTruncated: false, | ||
| 411 | stderrTruncated: false, | ||
| 412 | }; | ||
| 413 | }, | ||
| 414 | (effect, input) => | ||
| 415 | effect.pipe( | ||
| 416 | Effect.timeout(30_000), | ||
| 417 | Effect.mapError((error) => | ||
| 418 | error._tag === "ForgejoCliError" | ||
| 419 | ? error | ||
| 420 | : new ForgejoCliError({ | ||
| 421 | command: "fj", | ||
| 422 | cwd: input.cwd, | ||
| 423 | detail: "Forgejo API request failed or timed out.", | ||
| 424 | }), | ||
| 425 | ), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | |||
| 429 | const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* ( | ||
| 430 | cwd: string, | ||
| 431 | login: typeof ForgejoLoginSchema.Type, | ||
| 432 | ) { | ||
| 433 | const keys = yield* readKeys(cwd); | ||
| 434 | const token = keys.hosts[login.name]?.token; | ||
| 435 | const now = yield* Clock.currentTimeMillis; | ||
| 436 | const cached = authenticated.get(login.url); | ||
| 437 | if (token && cached?.token === token && now - cached.time < 30_000) return token; | ||
| 438 | yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] }); | ||
| 439 | // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token. | ||
| 440 | const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token; | ||
| 441 | if (!refreshed) | ||
| 442 | return yield* new ForgejoCliError({ | ||
| 443 | command: "fj", | ||
| 444 | cwd, | ||
| 445 | reason: "authentication", | ||
| 446 | detail: "fj has no credentials for this server. Authenticate again with fj.", | ||
| 447 | }); | ||
| 448 | authenticated.set(login.url, { token: refreshed, time: now }); | ||
| 449 | return refreshed; | ||
| 450 | }, authLock.withPermits(1)); | ||
| 451 | |||
| 452 | const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn( | ||
| 453 | "ForgejoCli.getAccount", | ||
| 454 | )(function* (input) { | ||
| 455 | const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl }); | ||
| 456 | const login = logins.find( | ||
| 457 | (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""), | ||
| 458 | ); | ||
| 459 | if (!login) | ||
| 460 | return yield* new ForgejoCliError({ | ||
| 461 | command: "fj", | ||
| 462 | cwd: input.cwd, | ||
| 463 | reason: "authentication", | ||
| 464 | detail: "fj has no credentials for this server.", | ||
| 465 | }); | ||
| 466 | const token = yield* authenticateFj(input.cwd, login); | ||
| 467 | const currentUser = yield* requestFj({ | ||
| 468 | cwd: input.cwd, | ||
| 469 | baseUrl: login.url.replace(/\/+$/, ""), | ||
| 470 | token, | ||
| 471 | path: "user", | ||
| 472 | }); | ||
| 473 | const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout); | ||
| 474 | if (Result.isFailure(user) || !user.success.login.trim()) | ||
| 475 | return yield* new ForgejoCliError({ | ||
| 476 | command: "fj", | ||
| 477 | cwd: input.cwd, | ||
| 478 | reason: "invalid-response", | ||
| 479 | detail: "Forgejo returned an invalid account response.", | ||
| 480 | }); | ||
| 481 | return user.success.login; | ||
| 482 | }); | ||
| 483 | |||
| 484 | const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* ( | ||
| 485 | input: ForgejoRepositoryInput, | ||
| 486 | hostOnly = false, | ||
| 487 | ) { | ||
| 488 | const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null; | ||
| 489 | let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find( | ||
| 490 | (value) => value && parseForgejoRemote(value), | ||
| 491 | ); | ||
| 492 | let remote = | ||
| 493 | referenceRemote ?? | ||
| 494 | (input.repository ? parseForgejoRemote(input.repository) : null) ?? | ||
| 495 | (input.context ? parseForgejoRemote(input.context.remoteUrl) : null); | ||
| 496 | if (!remote && (!input.repository || input.host)) { | ||
| 497 | const result = yield* process | ||
| 498 | .run({ | ||
| 499 | operation: "ForgejoCli.remote", | ||
| 500 | command: "git", | ||
| 501 | args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"], | ||
| 502 | cwd: input.cwd, | ||
| 503 | allowNonZeroExit: true, | ||
| 504 | }) | ||
| 505 | .pipe( | ||
| 506 | Effect.mapError( | ||
| 507 | (cause) => | ||
| 508 | new ForgejoCliError({ | ||
| 509 | command: "tea", | ||
| 510 | cwd: input.cwd, | ||
| 511 | detail: "Could not resolve the Forgejo repository remote.", | ||
| 512 | cause, | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | ); | ||
| 516 | if (input.host) { | ||
| 517 | const matchingUrls = [ | ||
| 518 | ...new Set( | ||
| 519 | result.stdout.split("\n").flatMap((line) => { | ||
| 520 | const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1]; | ||
| 521 | return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase() | ||
| 522 | ? [url] | ||
| 523 | : []; | ||
| 524 | }), | ||
| 525 | ), | ||
| 526 | ]; | ||
| 527 | const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))]; | ||
| 528 | remoteUrl = | ||
| 529 | matchingUrls.length === 1 | ||
| 530 | ? matchingUrls[0] | ||
| 531 | : origins.length === 1 | ||
| 532 | ? origins[0] | ||
| 533 | : undefined; | ||
| 534 | } else { | ||
| 535 | remoteUrl = result.stdout.trim(); | ||
| 536 | } | ||
| 537 | remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; | ||
| 538 | } | ||
| 539 | if ( | ||
| 540 | input.host && | ||
| 541 | !remote?.ssh && | ||
| 542 | remote?.host !== input.host.toLowerCase() && | ||
| 543 | remote?.hostname !== input.host.toLowerCase() | ||
| 544 | ) | ||
| 545 | remote = { | ||
| 546 | host: input.host.toLowerCase(), | ||
| 547 | hostname: input.host.split(":")[0] ?? input.host, | ||
| 548 | ssh: false, | ||
| 549 | path: remote?.path ?? "", | ||
| 550 | }; | ||
| 551 | const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl; | ||
| 552 | const fjLogins = yield* listLogins({ | ||
| 553 | cwd: input.cwd, | ||
| 554 | command: "fj", | ||
| 555 | ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}), | ||
| 556 | }); | ||
| 557 | const requestedHost = input.host ?? input.context?.requestedHost; | ||
| 558 | const matchHostOnly = hostOnly || (!!input.host && !remote?.path); | ||
| 559 | const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) => | ||
| 560 | remote | ||
| 561 | ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly) | ||
| 562 | : (logins.find((item) => item.default === "true") ?? | ||
| 563 | (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined)); | ||
| 564 | let login = selectLogin(fjLogins); | ||
| 565 | let command: "fj" | "tea" = "fj"; | ||
| 566 | if ( | ||
| 567 | !login && | ||
| 568 | fjLogins.some( | ||
| 569 | (item) => | ||
| 570 | !remote || | ||
| 571 | matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly), | ||
| 572 | ) | ||
| 573 | ) { | ||
| 574 | const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( | ||
| 575 | Effect.result, | ||
| 576 | ); | ||
| 577 | if (Result.isSuccess(available)) | ||
| 578 | return yield* new ForgejoCliError({ | ||
| 579 | command: "fj", | ||
| 580 | cwd: input.cwd, | ||
| 581 | reason: "authentication", | ||
| 582 | detail: "Multiple fj logins match this repository. Specify its full server URL.", | ||
| 583 | }); | ||
| 584 | if (available.failure.reason !== "missing-cli") return yield* available.failure; | ||
| 585 | } | ||
| 586 | if (login) { | ||
| 587 | const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result); | ||
| 588 | if (Result.isFailure(auth)) { | ||
| 589 | if (auth.failure.reason === "missing-cli") login = undefined; | ||
| 590 | else return yield* auth.failure; | ||
| 591 | } | ||
| 592 | } | ||
| 593 | if (!login) { | ||
| 594 | command = "tea"; | ||
| 595 | login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" })); | ||
| 596 | } | ||
| 597 | if (!login) | ||
| 598 | return yield* new ForgejoCliError({ | ||
| 599 | command: "tea", | ||
| 600 | cwd: input.cwd, | ||
| 601 | reason: "authentication", | ||
| 602 | detail: | ||
| 603 | "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.", | ||
| 604 | }); | ||
| 605 | if (hostOnly) | ||
| 606 | return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") }; | ||
| 607 | const path = | ||
| 608 | referenceRemote?.path ?? | ||
| 609 | (input.repository && !parseForgejoRemote(input.repository) | ||
| 610 | ? input.repository | ||
| 611 | : remote?.path) ?? | ||
| 612 | ""; | ||
| 613 | const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, ""); | ||
| 614 | const relativePath = | ||
| 615 | basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`) | ||
| 616 | ? path.slice(basePath.length + 1) | ||
| 617 | : path; | ||
| 618 | const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, ""); | ||
| 619 | if (command === "fj" && !repositoryPath.includes("/")) { | ||
| 620 | login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) }; | ||
| 621 | } | ||
| 622 | const repository = repositoryPath.includes("/") | ||
| 623 | ? repositoryPath | ||
| 624 | : `${login.user}/${repositoryPath}`; | ||
| 625 | if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) | ||
| 626 | return yield* new ForgejoCliError({ | ||
| 627 | command, | ||
| 628 | cwd: input.cwd, | ||
| 629 | detail: "Specify a Forgejo repository as owner/repository or its full server URL.", | ||
| 630 | }); | ||
| 631 | return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") }; | ||
| 632 | }); | ||
| 633 | const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input); | ||
| 634 | const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) { | ||
| 635 | const repository = yield* resolveTarget( | ||
| 636 | input, | ||
| 637 | input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"), | ||
| 638 | ); | ||
| 639 | const stdin = | ||
| 640 | input.body === undefined | ||
| 641 | ? undefined | ||
| 642 | : yield* encodeApiBody(input.body).pipe( | ||
| 643 | Effect.mapError( | ||
| 644 | (cause) => | ||
| 645 | new ForgejoCliError({ | ||
| 646 | command: "tea", | ||
| 647 | cwd: input.cwd, | ||
| 648 | detail: "Could not encode the Forgejo request body.", | ||
| 649 | cause, | ||
| 650 | }), | ||
| 651 | ), | ||
| 652 | ); | ||
| 653 | let path = input.path.replace(/^\/+/, ""); | ||
| 654 | if (input.repository && input.repository !== repository.repository) { | ||
| 655 | // Repository identities retain the server mount path; API routes do not. | ||
| 656 | const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 657 | if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) { | ||
| 658 | path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`; | ||
| 659 | } | ||
| 660 | } | ||
| 661 | if (repository.command === "fj") { | ||
| 662 | const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token; | ||
| 663 | if (!token) | ||
| 664 | return yield* new ForgejoCliError({ | ||
| 665 | command: "fj", | ||
| 666 | cwd: input.cwd, | ||
| 667 | reason: "authentication", | ||
| 668 | detail: "fj has no credentials for this server.", | ||
| 669 | }); | ||
| 670 | return yield* requestFj({ | ||
| 671 | cwd: input.cwd, | ||
| 672 | baseUrl: repository.baseUrl, | ||
| 673 | token, | ||
| 674 | path, | ||
| 675 | ...(input.method === undefined ? {} : { method: input.method }), | ||
| 676 | ...(stdin === undefined ? {} : { body: stdin }), | ||
| 677 | }); | ||
| 678 | } | ||
| 679 | const result = yield* execute({ | ||
| 680 | cwd: input.cwd, | ||
| 681 | args: [ | ||
| 682 | "api", | ||
| 683 | "--include", | ||
| 684 | "--login", | ||
| 685 | repository.login, | ||
| 686 | ...(repository.repository ? ["--repo", repository.repository] : []), | ||
| 687 | "--method", | ||
| 688 | input.method ?? "GET", | ||
| 689 | ...(input.body === undefined ? [] : ["--data", "@-"]), | ||
| 690 | `${repository.baseUrl}/api/v1/${path}`, | ||
| 691 | ], | ||
| 692 | ...(stdin === undefined ? {} : { stdin }), | ||
| 693 | }); | ||
| 694 | // tea reports HTTP failures with exit code zero; use its response status. | ||
| 695 | const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]); | ||
| 696 | if (!status || status >= 400) | ||
| 697 | return yield* new ForgejoCliError({ | ||
| 698 | command: "tea", | ||
| 699 | cwd: input.cwd, | ||
| 700 | ...(status ? { httpStatus: status } : {}), | ||
| 701 | ...(status === 401 | ||
| 702 | ? { reason: "authentication" as const } | ||
| 703 | : status === 403 | ||
| 704 | ? { reason: "forbidden" as const } | ||
| 705 | : status === 404 | ||
| 706 | ? { reason: "not-found" as const } | ||
| 707 | : status === 429 | ||
| 708 | ? { reason: "rate-limit" as const } | ||
| 709 | : {}), | ||
| 710 | detail: | ||
| 711 | status === 401 || status === 403 | ||
| 712 | ? "Forgejo denied access. Check this server's `tea login` credentials and permissions." | ||
| 713 | : status === 404 | ||
| 714 | ? "Forgejo repository or pull request was not found." | ||
| 715 | : status === 429 | ||
| 716 | ? "Forgejo API rate limit exceeded." | ||
| 717 | : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`, | ||
| 718 | }); | ||
| 719 | return result; | ||
| 720 | }); | ||
| 721 | return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api }); | ||
| 722 | }); | ||
| 723 | |||
| 724 | export const layer = Layer.effect(ForgejoCli, make); | ||
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts created+614| ... | @@ -0,0 +1,614 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as Result from "effect/Result"; | ||
| 4 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | ||
| 5 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 6 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 7 | import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; | ||
| 8 | import { | ||
| 9 | PullRequestProviderError, | ||
| 10 | type ProviderChangeRequestDetail, | ||
| 11 | type ProviderRepositoryRef, | ||
| 12 | type PullRequestProviderApi, | ||
| 13 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 14 | import { | ||
| 15 | ForgejoPullRequest, | ||
| 16 | ForgejoRepository, | ||
| 17 | ForgejoUser, | ||
| 18 | ForgejoComment, | ||
| 19 | ForgejoReview, | ||
| 20 | ForgejoReviewComment, | ||
| 21 | ForgejoCommit, | ||
| 22 | ForgejoStatus, | ||
| 23 | ForgejoLabel, | ||
| 24 | ForgejoReaction, | ||
| 25 | FORGEJO_REACTIONS, | ||
| 26 | forgejoChangeRequest, | ||
| 27 | forgejoActor, | ||
| 28 | forgejoComment, | ||
| 29 | forgejoReview, | ||
| 30 | forgejoReviewThread, | ||
| 31 | forgejoCommit, | ||
| 32 | forgejoChecks, | ||
| 33 | forgejoReactions, | ||
| 34 | } from "./forgejoPullRequestJson.ts"; | ||
| 35 | |||
| 36 | const CAPABILITIES: PullRequestCapabilities = { | ||
| 37 | diff: true, | ||
| 38 | viewedFiles: "environment", | ||
| 39 | comment: true, | ||
| 40 | actions: ["merge", "close", "reopen", "update-branch"], | ||
| 41 | mergeMethods: ["merge", "squash", "rebase"], | ||
| 42 | updateMethods: ["merge", "rebase"], | ||
| 43 | search: false, | ||
| 44 | reactions: true, | ||
| 45 | labels: true, | ||
| 46 | // Forgejo's public API has no thread replies/resolution or draft conversion endpoint. | ||
| 47 | review: { | ||
| 48 | inlineComment: true, | ||
| 49 | reply: false, | ||
| 50 | resolve: false, | ||
| 51 | verdicts: ["comment", "approve", "request-changes"], | ||
| 52 | }, | ||
| 53 | reviewers: { request: true, listCandidates: true }, | ||
| 54 | edit: { changeRequest: true, comment: true }, | ||
| 55 | }; | ||
| 56 | const repoPath = (input: ProviderRepositoryRef) => | ||
| 57 | `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 58 | const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 59 | `${repoPath(input)}/pulls/${input.number}`; | ||
| 60 | const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 61 | `${repoPath(input)}/issues/${input.number}`; | ||
| 62 | // Review IDs differ from the issue-comment IDs used by Forgejo's reactions API. | ||
| 63 | const reviewCommentId = (review: typeof ForgejoReview.Type) => | ||
| 64 | /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1]; | ||
| 65 | |||
| 66 | export const make = Effect.gen(function* () { | ||
| 67 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 68 | const failure = (operation: string, detail: string, cause?: unknown) => | ||
| 69 | new PullRequestProviderError({ | ||
| 70 | provider: "forgejo", | ||
| 71 | operation, | ||
| 72 | reason: "failed", | ||
| 73 | detail, | ||
| 74 | ...(cause === undefined ? {} : { cause }), | ||
| 75 | }); | ||
| 76 | const request = (input: ForgejoCli.ForgejoApiInput) => | ||
| 77 | cli.api(input).pipe( | ||
| 78 | Effect.mapError( | ||
| 79 | (error) => | ||
| 80 | new PullRequestProviderError({ | ||
| 81 | provider: "forgejo", | ||
| 82 | operation: input.path, | ||
| 83 | detail: error.detail, | ||
| 84 | cause: error, | ||
| 85 | reason: | ||
| 86 | error.reason === "missing-cli" | ||
| 87 | ? "missing-tool" | ||
| 88 | : error.reason === "authentication" | ||
| 89 | ? "unauthenticated" | ||
| 90 | : error.reason === "rate-limit" | ||
| 91 | ? "rate-limited" | ||
| 92 | : error.reason === "not-found" | ||
| 93 | ? "not-found" | ||
| 94 | : "failed", | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | ); | ||
| 98 | const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>( | ||
| 99 | input: ForgejoCli.ForgejoApiInput, | ||
| 100 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 101 | ) { | ||
| 102 | const result = yield* request(input); | ||
| 103 | if (result.stdoutTruncated) | ||
| 104 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); | ||
| 105 | const decoded = decodeJsonResult(schema)(result.stdout); | ||
| 106 | return Result.isSuccess(decoded) | ||
| 107 | ? decoded.success | ||
| 108 | : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); | ||
| 109 | }); | ||
| 110 | const readArray = <A>( | ||
| 111 | input: ForgejoCli.ForgejoApiInput, | ||
| 112 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 113 | ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? [])); | ||
| 114 | const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>( | ||
| 115 | input: ForgejoCli.ForgejoApiInput, | ||
| 116 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 117 | index: number, | ||
| 118 | ) { | ||
| 119 | const result = yield* request({ | ||
| 120 | ...input, | ||
| 121 | path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`, | ||
| 122 | }); | ||
| 123 | if (result.stdoutTruncated) | ||
| 124 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); | ||
| 125 | const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout); | ||
| 126 | if (Result.isFailure(decoded)) | ||
| 127 | return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); | ||
| 128 | const rows = decoded.success ?? []; | ||
| 129 | const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; | ||
| 130 | return { | ||
| 131 | rows, | ||
| 132 | more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)), | ||
| 133 | }; | ||
| 134 | }); | ||
| 135 | // Use a stable, small page size that also works with Forgejo's default maximum of 50. | ||
| 136 | const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>( | ||
| 137 | input: ForgejoCli.ForgejoApiInput, | ||
| 138 | schema: Schema.Codec<A, unknown, never, never>, | ||
| 139 | limit = 500, | ||
| 140 | ) { | ||
| 141 | const items: A[] = []; | ||
| 142 | for (let index = 1; items.length < limit; index++) { | ||
| 143 | const { rows, more } = yield* readPage(input, schema, index); | ||
| 144 | items.push(...rows); | ||
| 145 | if (!more) return { items, truncated: false }; | ||
| 146 | } | ||
| 147 | return { items, truncated: true }; | ||
| 148 | }); | ||
| 149 | const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid); | ||
| 150 | const getPull = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 151 | read( | ||
| 152 | { | ||
| 153 | cwd: input.cwd, | ||
| 154 | repository: input.repository, | ||
| 155 | host: input.host, | ||
| 156 | path: pullPath(input), | ||
| 157 | }, | ||
| 158 | ForgejoPullRequest, | ||
| 159 | ); | ||
| 160 | const getRepo = (input: ProviderRepositoryRef) => | ||
| 161 | read({ ...input, path: repoPath(input) }, ForgejoRepository); | ||
| 162 | const getViewer = (input: { | ||
| 163 | readonly cwd: string; | ||
| 164 | readonly repository?: string; | ||
| 165 | readonly host?: string; | ||
| 166 | }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login)); | ||
| 167 | const permissions = ( | ||
| 168 | repo: typeof ForgejoRepository.Type, | ||
| 169 | pr: typeof ForgejoPullRequest.Type, | ||
| 170 | viewer: string, | ||
| 171 | ): PullRequestViewerPermissions => { | ||
| 172 | const canWrite = repo.permissions?.push ?? false; | ||
| 173 | const canEdit = canWrite || pr.user?.login === viewer; | ||
| 174 | const active = !repo.archived; | ||
| 175 | return { | ||
| 176 | actions: active | ||
| 177 | ? CAPABILITIES.actions.filter((action) => | ||
| 178 | action === "merge" || action === "update-branch" ? canWrite : canEdit, | ||
| 179 | ) | ||
| 180 | : [], | ||
| 181 | comment: active && (!pr.is_locked || canWrite), | ||
| 182 | resolve: false, | ||
| 183 | verdicts: active | ||
| 184 | ? pr.user?.login === viewer | ||
| 185 | ? ["comment"] | ||
| 186 | : CAPABILITIES.review.verdicts | ||
| 187 | : [], | ||
| 188 | requestReviewers: active && canEdit, | ||
| 189 | labels: active && canWrite, | ||
| 190 | updateMethods: | ||
| 191 | active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [], | ||
| 192 | }; | ||
| 193 | }; | ||
| 194 | const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* ( | ||
| 195 | input: ProviderRepositoryRef & { readonly number: number }, | ||
| 196 | ) { | ||
| 197 | const [repo, pr, viewer] = yield* Effect.all( | ||
| 198 | [getRepo(input), getPull(input), getViewer(input)], | ||
| 199 | { concurrency: 3 }, | ||
| 200 | ); | ||
| 201 | return permissions(repo, pr, viewer); | ||
| 202 | }); | ||
| 203 | const unsupported = (operation: string) => | ||
| 204 | Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); | ||
| 205 | const provider: PullRequestProviderApi = { | ||
| 206 | kind: "forgejo", | ||
| 207 | capabilities: CAPABILITIES, | ||
| 208 | getViewer, | ||
| 209 | listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( | ||
| 210 | function* (input) { | ||
| 211 | const offset = input.cursor?.delivered ?? 0; | ||
| 212 | const items: ReturnType<typeof forgejoChangeRequest>[] = []; | ||
| 213 | const state = input.state === "merged" ? "closed" : input.state; | ||
| 214 | const query = { | ||
| 215 | ...input, | ||
| 216 | path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`, | ||
| 217 | }; | ||
| 218 | // Self-hosted servers may cap pages below 50. Establish their actual page size before | ||
| 219 | // translating the service's row offset into an API page number. | ||
| 220 | const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1); | ||
| 221 | const pageSize = first.rows.length || 50; | ||
| 222 | const firstIndex = Math.floor(offset / pageSize) + 1; | ||
| 223 | let consumed = 0; | ||
| 224 | let more = true; | ||
| 225 | for (let index = firstIndex; more && consumed < input.limit; index++) { | ||
| 226 | const result = | ||
| 227 | index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index); | ||
| 228 | const rows = result.rows; | ||
| 229 | const start = index === firstIndex ? offset % pageSize : 0; | ||
| 230 | const countBefore = consumed; | ||
| 231 | for (const row of rows.slice(start)) { | ||
| 232 | if (consumed >= input.limit) break; | ||
| 233 | consumed++; | ||
| 234 | if (row) items.push(forgejoChangeRequest(row)); | ||
| 235 | } | ||
| 236 | more = result.more || rows.length - start > consumed - countBefore; | ||
| 237 | } | ||
| 238 | return { items, truncated: more, continues: true, cursorAdvance: consumed }; | ||
| 239 | }, | ||
| 240 | ), | ||
| 241 | getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)), | ||
| 242 | getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")( | ||
| 243 | function* (input) { | ||
| 244 | const pr = yield* getPull(input); | ||
| 245 | const statuses = yield* page( | ||
| 246 | { | ||
| 247 | ...input, | ||
| 248 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, | ||
| 249 | }, | ||
| 250 | ForgejoStatus, | ||
| 251 | ); | ||
| 252 | return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) }; | ||
| 253 | }, | ||
| 254 | ), | ||
| 255 | getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) { | ||
| 256 | const [pr, repo, viewer] = yield* Effect.all( | ||
| 257 | [getPull(input), getRepo(input), getViewer(input)], | ||
| 258 | { concurrency: 3 }, | ||
| 259 | ); | ||
| 260 | const statuses = yield* page( | ||
| 261 | { | ||
| 262 | ...input, | ||
| 263 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, | ||
| 264 | }, | ||
| 265 | ForgejoStatus, | ||
| 266 | ); | ||
| 267 | return { | ||
| 268 | ...forgejoChangeRequest(pr), | ||
| 269 | body: pr.body ?? "", | ||
| 270 | changedFiles: pr.changed_files ?? 0, | ||
| 271 | reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { | ||
| 272 | const actor = forgejoActor(user); | ||
| 273 | return actor ? [actor] : []; | ||
| 274 | }), | ||
| 275 | checks: forgejoChecks(statuses.items), | ||
| 276 | baseComparison: !pr.merge_base | ||
| 277 | ? "unknown" | ||
| 278 | : pr.merge_base === pr.base.sha | ||
| 279 | ? "up-to-date" | ||
| 280 | : "behind", | ||
| 281 | viewerPermissions: permissions(repo, pr, viewer), | ||
| 282 | mergeCapabilities: { | ||
| 283 | merge: repo.allow_merge_commits ?? true, | ||
| 284 | squash: repo.allow_squash_merge ?? true, | ||
| 285 | rebase: repo.allow_rebase ?? true, | ||
| 286 | }, | ||
| 287 | } satisfies ProviderChangeRequestDetail; | ||
| 288 | }), | ||
| 289 | getViewerPermissions: getPermissions, | ||
| 290 | getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")( | ||
| 291 | function* (input) { | ||
| 292 | const [comments, reviews, commits, reactions, viewer] = yield* Effect.all( | ||
| 293 | [ | ||
| 294 | // Issue comments ignore page/limit; fetch this unpaginated endpoint once. | ||
| 295 | readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe( | ||
| 296 | Effect.map((items) => ({ | ||
| 297 | items: items.slice(0, 500), | ||
| 298 | truncated: items.length > 500, | ||
| 299 | })), | ||
| 300 | ), | ||
| 301 | page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview), | ||
| 302 | page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit), | ||
| 303 | page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction), | ||
| 304 | getViewer(input), | ||
| 305 | ], | ||
| 306 | { concurrency: 5 }, | ||
| 307 | ); | ||
| 308 | const reviewComments = yield* Effect.forEach( | ||
| 309 | reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"), | ||
| 310 | (review) => | ||
| 311 | readArray( | ||
| 312 | { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` }, | ||
| 313 | ForgejoReviewComment, | ||
| 314 | ), | ||
| 315 | { concurrency: 4 }, | ||
| 316 | ); | ||
| 317 | const allInline = reviewComments.flat(); | ||
| 318 | const inline = allInline.slice(0, 500); | ||
| 319 | const entries = [ | ||
| 320 | ...comments.items.map((comment) => ({ | ||
| 321 | comment: forgejoComment(comment), | ||
| 322 | reactionId: String(comment.id), | ||
| 323 | })), | ||
| 324 | ...reviews.items | ||
| 325 | .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW") | ||
| 326 | .map((review) => ({ | ||
| 327 | comment: forgejoReview(review), | ||
| 328 | reactionId: reviewCommentId(review), | ||
| 329 | })), | ||
| 330 | ...inline.map((comment) => ({ | ||
| 331 | comment: { | ||
| 332 | ...forgejoComment(comment), | ||
| 333 | kind: "review-comment" as const, | ||
| 334 | path: comment.path, | ||
| 335 | }, | ||
| 336 | reactionId: String(comment.id), | ||
| 337 | })), | ||
| 338 | ]; | ||
| 339 | const enriched = yield* Effect.forEach( | ||
| 340 | entries, | ||
| 341 | ({ comment, reactionId }) => | ||
| 342 | (reactionId === undefined | ||
| 343 | ? Effect.succeed([]) | ||
| 344 | : readArray( | ||
| 345 | { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` }, | ||
| 346 | ForgejoReaction, | ||
| 347 | ) | ||
| 348 | ).pipe( | ||
| 349 | Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })), | ||
| 350 | ), | ||
| 351 | { concurrency: 4 }, | ||
| 352 | ); | ||
| 353 | const byId = new Map(enriched.map((comment) => [comment.id, comment])); | ||
| 354 | const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); | ||
| 355 | return { | ||
| 356 | comments: timeline, | ||
| 357 | commentCount: timeline.length, | ||
| 358 | commentsTruncated: | ||
| 359 | comments.truncated || reviews.truncated || allInline.length > inline.length, | ||
| 360 | reviewThreads: inline.map((comment) => ({ | ||
| 361 | ...forgejoReviewThread(comment), | ||
| 362 | comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)], | ||
| 363 | })), | ||
| 364 | commits: commits.items.map(forgejoCommit), | ||
| 365 | reactions: forgejoReactions(reactions.items, viewer), | ||
| 366 | }; | ||
| 367 | }, | ||
| 368 | ), | ||
| 369 | getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) { | ||
| 370 | const result = yield* request({ ...input, path: `${pullPath(input)}.diff` }); | ||
| 371 | if (result.stdoutTruncated) { | ||
| 372 | return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit."); | ||
| 373 | } | ||
| 374 | const revisions = new Map(parseDiffFileRevisions(result.stdout)); | ||
| 375 | for (const path of input.paths) { | ||
| 376 | if (!revisions.has(path)) revisions.set(path, ""); | ||
| 377 | } | ||
| 378 | return { revisions, complete: true }; | ||
| 379 | }), | ||
| 380 | getDiff: (input) => | ||
| 381 | request({ | ||
| 382 | ...input, | ||
| 383 | path: input.commit | ||
| 384 | ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff` | ||
| 385 | : `${pullPath(input)}.diff`, | ||
| 386 | }).pipe( | ||
| 387 | Effect.map((result) => ({ | ||
| 388 | patch: result.stdout, | ||
| 389 | truncated: result.stdoutTruncated, | ||
| 390 | nextCursor: null, | ||
| 391 | })), | ||
| 392 | ), | ||
| 393 | getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")( | ||
| 394 | function* (input) { | ||
| 395 | const pr = yield* getPull(input); | ||
| 396 | const commit = input.commit | ||
| 397 | ? yield* read( | ||
| 398 | { | ||
| 399 | ...input, | ||
| 400 | path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`, | ||
| 401 | }, | ||
| 402 | ForgejoCommit, | ||
| 403 | ) | ||
| 404 | : null; | ||
| 405 | const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha; | ||
| 406 | const newRef = input.commit ?? pr.head.sha; | ||
| 407 | const content = (repository: string, ref: string, path: string) => | ||
| 408 | read( | ||
| 409 | { | ||
| 410 | ...input, | ||
| 411 | repository, | ||
| 412 | path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, | ||
| 413 | }, | ||
| 414 | Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }), | ||
| 415 | ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8"))); | ||
| 416 | const [oldContents, newContents] = yield* Effect.all( | ||
| 417 | [ | ||
| 418 | input.changeType === "new" || !oldRef | ||
| 419 | ? Effect.succeed("") | ||
| 420 | : content(input.repository, oldRef, input.oldPath), | ||
| 421 | input.changeType === "deleted" | ||
| 422 | ? Effect.succeed("") | ||
| 423 | : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath), | ||
| 424 | ], | ||
| 425 | { concurrency: 2 }, | ||
| 426 | ); | ||
| 427 | return { oldContents, newContents }; | ||
| 428 | }, | ||
| 429 | ), | ||
| 430 | runAction: (input) => { | ||
| 431 | switch (input.action) { | ||
| 432 | case "merge": | ||
| 433 | return write({ | ||
| 434 | ...input, | ||
| 435 | path: `${pullPath(input)}/merge`, | ||
| 436 | method: "POST", | ||
| 437 | body: { Do: input.mergeMethod ?? "merge" }, | ||
| 438 | }); | ||
| 439 | case "close": | ||
| 440 | case "reopen": | ||
| 441 | return write({ | ||
| 442 | ...input, | ||
| 443 | path: pullPath(input), | ||
| 444 | method: "PATCH", | ||
| 445 | body: { state: input.action === "close" ? "closed" : "open" }, | ||
| 446 | }); | ||
| 447 | case "update-branch": | ||
| 448 | return write({ | ||
| 449 | ...input, | ||
| 450 | path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`, | ||
| 451 | method: "POST", | ||
| 452 | }); | ||
| 453 | default: | ||
| 454 | return unsupported(input.action); | ||
| 455 | } | ||
| 456 | }, | ||
| 457 | updateChangeRequest: (input) => | ||
| 458 | write({ | ||
| 459 | ...input, | ||
| 460 | path: pullPath(input), | ||
| 461 | method: "PATCH", | ||
| 462 | body: { | ||
| 463 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 464 | ...(input.body === undefined ? {} : { body: input.body }), | ||
| 465 | }, | ||
| 466 | }), | ||
| 467 | comment: (input) => | ||
| 468 | write({ | ||
| 469 | ...input, | ||
| 470 | path: `${issuePath(input)}/comments`, | ||
| 471 | method: "POST", | ||
| 472 | body: { body: input.body }, | ||
| 473 | }), | ||
| 474 | updateComment: (input) => | ||
| 475 | write({ | ||
| 476 | ...input, | ||
| 477 | path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`, | ||
| 478 | method: "PATCH", | ||
| 479 | body: { body: input.body }, | ||
| 480 | }), | ||
| 481 | submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) { | ||
| 482 | const pr = yield* getPull(input); | ||
| 483 | yield* write({ | ||
| 484 | ...input, | ||
| 485 | path: `${pullPath(input)}/reviews`, | ||
| 486 | method: "POST", | ||
| 487 | body: { | ||
| 488 | event: | ||
| 489 | input.verdict === "approve" | ||
| 490 | ? "APPROVED" | ||
| 491 | : input.verdict === "request-changes" | ||
| 492 | ? "REQUEST_CHANGES" | ||
| 493 | : "COMMENT", | ||
| 494 | body: input.body, | ||
| 495 | commit_id: pr.head.sha, | ||
| 496 | comments: input.comments.map((comment) => { | ||
| 497 | const position = comment.position; | ||
| 498 | const old = | ||
| 499 | position.kind === "deleted" || | ||
| 500 | (position.kind === "context" && position.side === "left"); | ||
| 501 | return { | ||
| 502 | path: old ? (comment.oldPath ?? comment.path) : comment.path, | ||
| 503 | body: comment.body, | ||
| 504 | old_position: old ? position.oldLine : 0, | ||
| 505 | new_position: old ? 0 : position.newLine, | ||
| 506 | }; | ||
| 507 | }), | ||
| 508 | }, | ||
| 509 | }); | ||
| 510 | }), | ||
| 511 | listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")( | ||
| 512 | function* (input) { | ||
| 513 | const [pr, users] = yield* Effect.all( | ||
| 514 | [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)], | ||
| 515 | { concurrency: 2 }, | ||
| 516 | ); | ||
| 517 | return { | ||
| 518 | candidates: users.items | ||
| 519 | .filter((user) => user.login !== pr.user?.login) | ||
| 520 | .flatMap((user) => { | ||
| 521 | const actor = forgejoActor(user); | ||
| 522 | return actor | ||
| 523 | ? [ | ||
| 524 | { | ||
| 525 | ...actor, | ||
| 526 | id: user.login, | ||
| 527 | kind: "user" as const, | ||
| 528 | isRequested: | ||
| 529 | pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ?? | ||
| 530 | false, | ||
| 531 | }, | ||
| 532 | ] | ||
| 533 | : []; | ||
| 534 | }), | ||
| 535 | truncated: users.truncated, | ||
| 536 | }; | ||
| 537 | }, | ||
| 538 | ), | ||
| 539 | setReviewerRequest: (input) => | ||
| 540 | write({ | ||
| 541 | ...input, | ||
| 542 | path: `${pullPath(input)}/requested_reviewers`, | ||
| 543 | method: input.requested ? "POST" : "DELETE", | ||
| 544 | body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) }, | ||
| 545 | }), | ||
| 546 | listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")( | ||
| 547 | function* (input) { | ||
| 548 | const [pr, labels] = yield* Effect.all( | ||
| 549 | [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)], | ||
| 550 | { concurrency: 2 }, | ||
| 551 | ); | ||
| 552 | return { | ||
| 553 | candidates: labels.items.map((label) => ({ | ||
| 554 | name: label.name, | ||
| 555 | color: label.color ?? null, | ||
| 556 | description: label.description ?? null, | ||
| 557 | isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false, | ||
| 558 | })), | ||
| 559 | truncated: labels.truncated, | ||
| 560 | }; | ||
| 561 | }, | ||
| 562 | ), | ||
| 563 | setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) { | ||
| 564 | const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); | ||
| 565 | const selected = labels.items.filter((label) => input.labels.includes(label.name)); | ||
| 566 | if (selected.length !== input.labels.length) | ||
| 567 | return yield* failure("setLabels", "One or more requested labels could not be found."); | ||
| 568 | if (input.applied) | ||
| 569 | yield* write({ | ||
| 570 | ...input, | ||
| 571 | path: `${issuePath(input)}/labels`, | ||
| 572 | method: "POST", | ||
| 573 | body: { labels: selected.map((label) => label.id) }, | ||
| 574 | }); | ||
| 575 | else | ||
| 576 | yield* Effect.forEach( | ||
| 577 | selected, | ||
| 578 | (label) => | ||
| 579 | write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }), | ||
| 580 | { concurrency: 1 }, | ||
| 581 | ); | ||
| 582 | }), | ||
| 583 | setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) { | ||
| 584 | let commentId = input.subjectId; | ||
| 585 | if (commentId?.startsWith("review:")) { | ||
| 586 | const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1]; | ||
| 587 | if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID."); | ||
| 588 | const review = yield* read( | ||
| 589 | { ...input, path: `${pullPath(input)}/reviews/${reviewId}` }, | ||
| 590 | ForgejoReview, | ||
| 591 | ); | ||
| 592 | commentId = reviewCommentId(review); | ||
| 593 | if (!commentId) | ||
| 594 | return yield* failure( | ||
| 595 | "setReaction", | ||
| 596 | "Forgejo did not return a comment ID for this review.", | ||
| 597 | ); | ||
| 598 | } | ||
| 599 | if (commentId && !/^[1-9]\d*$/.test(commentId)) | ||
| 600 | return yield* failure("setReaction", "Invalid Forgejo comment ID."); | ||
| 601 | yield* write({ | ||
| 602 | ...input, | ||
| 603 | path: commentId | ||
| 604 | ? `${repoPath(input)}/issues/comments/${commentId}/reactions` | ||
| 605 | : `${issuePath(input)}/reactions`, | ||
| 606 | method: input.reacted ? "POST" : "DELETE", | ||
| 607 | body: { content: FORGEJO_REACTIONS[input.content] }, | ||
| 608 | }); | ||
| 609 | }), | ||
| 610 | replyToThread: () => unsupported("thread replies"), | ||
| 611 | setThreadResolution: () => unsupported("thread resolution"), | ||
| 612 | }; | ||
| 613 | return provider; | ||
| 614 | }); | ||
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts created+1283| ... | @@ -0,0 +1,1283 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import type * as Context from "effect/Context"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 9 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; | ||
| 10 | import * as HttpClient from "effect/http/HttpClient"; | ||
| 11 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | ||
| 12 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | ||
| 13 | import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; | ||
| 14 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | ||
| 15 | |||
| 16 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 17 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | ||
| 18 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | ||
| 19 | |||
| 20 | const processOutput = TestSourceControlHost.processOutput; | ||
| 21 | |||
| 22 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 23 | const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 24 | |||
| 25 | it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { | ||
| 26 | const methods: string[] = []; | ||
| 27 | const fetchReview = async ( | ||
| 28 | ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>> | ||
| 29 | ) => { | ||
| 30 | const request = new Request(input instanceof Request ? input.url : String(input), { | ||
| 31 | ...(init?.method === undefined ? {} : { method: init.method }), | ||
| 32 | ...(init?.headers === undefined ? {} : { headers: init.headers }), | ||
| 33 | ...(init?.body === undefined ? {} : { body: init.body }), | ||
| 34 | }); | ||
| 35 | methods.push(request.method); | ||
| 36 | if (request.method === "GET") { | ||
| 37 | assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); | ||
| 38 | return new Response( | ||
| 39 | encodeJson({ | ||
| 40 | number: 42, | ||
| 41 | title: "Review target", | ||
| 42 | body: "", | ||
| 43 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 44 | user: { login: "maria" }, | ||
| 45 | state: "open", | ||
| 46 | merged: false, | ||
| 47 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 48 | base: { ref: "main", sha: "base", repo: null }, | ||
| 49 | created_at: "2026-09-13T00:00:00Z", | ||
| 50 | updated_at: "2026-09-13T00:00:00Z", | ||
| 51 | closed_at: null, | ||
| 52 | merged_at: null, | ||
| 53 | labels: [], | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | } | ||
| 57 | assert.strictEqual(request.method, "POST"); | ||
| 58 | assert.strictEqual( | ||
| 59 | request.url, | ||
| 60 | "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", | ||
| 61 | ); | ||
| 62 | assert.deepStrictEqual(JSON.parse(await request.text()), { | ||
| 63 | event: "COMMENT", | ||
| 64 | body: "Review summary", | ||
| 65 | commit_id: "head", | ||
| 66 | comments: [], | ||
| 67 | }); | ||
| 68 | return new Response('{"id":1}', { status: 200 }); | ||
| 69 | }; | ||
| 70 | return Effect.gen(function* () { | ||
| 71 | const cli = yield* ForgejoCli.make; | ||
| 72 | const provider = yield* ForgejoPullRequestProvider.make.pipe( | ||
| 73 | Effect.provideService(ForgejoCli.ForgejoCli, cli), | ||
| 74 | ); | ||
| 75 | yield* provider.submitReview({ | ||
| 76 | cwd: "/repo", | ||
| 77 | repository: "maria/project", | ||
| 78 | host: "forgejo.test", | ||
| 79 | number: 42, | ||
| 80 | verdict: "comment", | ||
| 81 | body: "Review summary", | ||
| 82 | comments: [], | ||
| 83 | }); | ||
| 84 | assert.deepStrictEqual(methods, ["GET", "POST"]); | ||
| 85 | }).pipe( | ||
| 86 | Effect.provideService( | ||
| 87 | FetchHttpClient.Fetch, | ||
| 88 | Object.assign(fetchReview, { preconnect: () => undefined }), | ||
| 89 | ), | ||
| 90 | Effect.provide(FetchHttpClient.layer), | ||
| 91 | Effect.provideService( | ||
| 92 | FileSystem.FileSystem, | ||
| 93 | FileSystem.makeNoop({ | ||
| 94 | exists: () => Effect.succeed(true), | ||
| 95 | readFileString: () => | ||
| 96 | Effect.succeed( | ||
| 97 | encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), | ||
| 98 | ), | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | Effect.provide( | ||
| 102 | TestSourceControlHost.layer({ | ||
| 103 | process: { | ||
| 104 | run: (input) => { | ||
| 105 | if (input.command === "git") { | ||
| 106 | assert.deepStrictEqual(input.args, ["remote", "-v"]); | ||
| 107 | return Effect.succeed( | ||
| 108 | processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | assert.strictEqual(input.command, "fj"); | ||
| 112 | assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); | ||
| 113 | return Effect.succeed(processOutput("")); | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | }), | ||
| 117 | ), | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it.effect("reads Forgejo checks without repository or viewer requests", () => { | ||
| 122 | const paths: string[] = []; | ||
| 123 | return Effect.gen(function* () { | ||
| 124 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 125 | const read = provider.getChangeRequestChecks; | ||
| 126 | if (read === undefined) return yield* Effect.die("checks read missing"); | ||
| 127 | const result = yield* read({ | ||
| 128 | cwd: "/repo", | ||
| 129 | repository: "acme/web", | ||
| 130 | host: "forgejo.test", | ||
| 131 | number: 1, | ||
| 132 | }); | ||
| 133 | assert.strictEqual(result.state, "open"); | ||
| 134 | assert.strictEqual(result.checks[0]?.status, "failure"); | ||
| 135 | assert.deepStrictEqual(paths, [ | ||
| 136 | "repos/acme/web/pulls/1", | ||
| 137 | "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", | ||
| 138 | "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", | ||
| 139 | ]); | ||
| 140 | }).pipe( | ||
| 141 | Effect.provide( | ||
| 142 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 143 | api: (input) => { | ||
| 144 | paths.push(input.path); | ||
| 145 | assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); | ||
| 146 | return Effect.succeed( | ||
| 147 | processOutput( | ||
| 148 | input.path.endsWith("pulls/1") | ||
| 149 | ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, | ||
| 150 | "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, | ||
| 151 | "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` | ||
| 152 | : input.path.endsWith("page=1") | ||
| 153 | ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` | ||
| 154 | : "[]", | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | }, | ||
| 158 | }), | ||
| 159 | ), | ||
| 160 | ); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it.effect.each([ | ||
| 164 | ["Ready", false, true, false, "mergeable"], | ||
| 165 | ["Draft", true, false, true, "unknown"], | ||
| 166 | ["Blocked", false, false, false, "unknown"], | ||
| 167 | ["Unchecked", false, undefined, false, "unknown"], | ||
| 168 | ["WIP: Legacy draft", undefined, false, true, "unknown"], | ||
| 169 | ["[WIP] Legacy draft", undefined, false, true, "unknown"], | ||
| 170 | ["WIP: Explicitly ready", false, false, false, "unknown"], | ||
| 171 | ] as const)( | ||
| 172 | "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail", | ||
| 173 | ([title, draft, mergeable, isDraft, mergeability]) => { | ||
| 174 | const pr = { | ||
| 175 | number: 42, | ||
| 176 | title, | ||
| 177 | body: "", | ||
| 178 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 179 | user: { login: "maria" }, | ||
| 180 | state: "open", | ||
| 181 | merged: false, | ||
| 182 | ...(draft === undefined ? {} : { draft }), | ||
| 183 | ...(mergeable === undefined ? {} : { mergeable }), | ||
| 184 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 185 | base: { ref: "main", sha: "base", repo: null }, | ||
| 186 | created_at: "2026-09-16T00:00:00Z", | ||
| 187 | updated_at: "2026-09-16T00:00:00Z", | ||
| 188 | closed_at: null, | ||
| 189 | merged_at: null, | ||
| 190 | labels: [], | ||
| 191 | }; | ||
| 192 | return Effect.gen(function* () { | ||
| 193 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 194 | const readSummary = provider.getChangeRequestSummary; | ||
| 195 | if (readSummary === undefined) return yield* Effect.die("summary read missing"); | ||
| 196 | const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 }; | ||
| 197 | const list = yield* provider.listChangeRequests({ | ||
| 198 | ...input, | ||
| 199 | state: "open", | ||
| 200 | involvement: "all", | ||
| 201 | viewer: "maria", | ||
| 202 | limit: 10, | ||
| 203 | }); | ||
| 204 | const summary = yield* readSummary(input); | ||
| 205 | const detail = yield* provider.getChangeRequest(input); | ||
| 206 | assert.strictEqual(list.items.length, 1); | ||
| 207 | for (const result of [list.items[0]!, summary, detail]) { | ||
| 208 | assert.strictEqual(result.mergeability, mergeability); | ||
| 209 | assert.strictEqual(result.isDraft, isDraft); | ||
| 210 | } | ||
| 211 | }).pipe( | ||
| 212 | Effect.provide( | ||
| 213 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 214 | api: (input) => { | ||
| 215 | const [path, query] = input.path.split("?"); | ||
| 216 | let response: unknown; | ||
| 217 | switch (path) { | ||
| 218 | case "repos/maria/project/pulls": | ||
| 219 | response = new URLSearchParams(query).get("page") === "1" ? [pr] : []; | ||
| 220 | break; | ||
| 221 | case "repos/maria/project/pulls/42": | ||
| 222 | response = pr; | ||
| 223 | break; | ||
| 224 | case "repos/maria/project": | ||
| 225 | response = { full_name: "maria/project", permissions: { push: true, admin: true } }; | ||
| 226 | break; | ||
| 227 | case "user": | ||
| 228 | response = pr.user; | ||
| 229 | break; | ||
| 230 | case "repos/maria/project/statuses/head": | ||
| 231 | response = []; | ||
| 232 | break; | ||
| 233 | default: | ||
| 234 | return Effect.die(`Unexpected Forgejo request: ${input.path}`); | ||
| 235 | } | ||
| 236 | return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); | ||
| 237 | }, | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | }, | ||
| 242 | ); | ||
| 243 | |||
| 244 | it.effect("loads Forgejo pull request references from files and commits views", () => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const provider = yield* ForgejoSourceControlProvider.make; | ||
| 247 | for (const reference of [ | ||
| 248 | "42", | ||
| 249 | "#42", | ||
| 250 | "https://forgejo.test/maria/project/pulls/42", | ||
| 251 | "https://forgejo.test/maria/project/pulls/42/", | ||
| 252 | "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", | ||
| 253 | "http://forgejo.test:3000/git/maria/project/pulls/42/commits", | ||
| 254 | ]) { | ||
| 255 | const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); | ||
| 256 | assert.strictEqual(result.number, 42); | ||
| 257 | assert.strictEqual(result.title, "Forgejo view reference"); | ||
| 258 | } | ||
| 259 | const invalid = yield* provider | ||
| 260 | .getChangeRequest({ | ||
| 261 | cwd: "/repo", | ||
| 262 | reference: "https://forgejo.test/maria/project/pulls/42invalid/files", | ||
| 263 | }) | ||
| 264 | .pipe(Effect.result); | ||
| 265 | assert.strictEqual(invalid._tag, "Failure"); | ||
| 266 | }).pipe( | ||
| 267 | Effect.provide( | ||
| 268 | Layer.mergeAll( | ||
| 269 | Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), | ||
| 270 | TestSourceControlHost.layer({ process: {} }), | ||
| 271 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 272 | resolveRepository: () => | ||
| 273 | Effect.succeed({ | ||
| 274 | login: "work", | ||
| 275 | repository: "maria/project", | ||
| 276 | baseUrl: "https://forgejo.test", | ||
| 277 | }), | ||
| 278 | api: (input) => { | ||
| 279 | assert.strictEqual(input.path, "repos/maria/project/pulls/42"); | ||
| 280 | return encodeJsonEffect({ | ||
| 281 | number: 42, | ||
| 282 | title: "Forgejo view reference", | ||
| 283 | html_url: "https://forgejo.test/maria/project/pulls/42", | ||
| 284 | state: "open", | ||
| 285 | merged: false, | ||
| 286 | base: { ref: "main", sha: "base", repo: null }, | ||
| 287 | head: { ref: "feature", sha: "head", repo: null }, | ||
| 288 | }).pipe(Effect.orDie, Effect.map(processOutput)); | ||
| 289 | }, | ||
| 290 | }), | ||
| 291 | ), | ||
| 292 | ), | ||
| 293 | ), | ||
| 294 | ); | ||
| 295 | |||
| 296 | it.effect( | ||
| 297 | "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", | ||
| 298 | () => { | ||
| 299 | const user = { login: "maria" }; | ||
| 300 | const review = { | ||
| 301 | id: 8, | ||
| 302 | body: "review body", | ||
| 303 | user, | ||
| 304 | state: "COMMENT", | ||
| 305 | submitted_at: "2026-09-12T00:00:00Z", | ||
| 306 | html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", | ||
| 307 | comments_count: 1, | ||
| 308 | }; | ||
| 309 | const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; | ||
| 310 | const responses: Record<string, unknown> = { | ||
| 311 | user, | ||
| 312 | "repos/maria/project/issues/2/comments": [comment], | ||
| 313 | "repos/maria/project/pulls/2/reviews": [review], | ||
| 314 | "repos/maria/project/pulls/2/reviews/8": review, | ||
| 315 | "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, | ||
| 316 | "repos/maria/project/pulls/2/commits": [], | ||
| 317 | "repos/maria/project/issues/2/reactions": [], | ||
| 318 | "repos/maria/project/pulls/2/reviews/8/comments": [ | ||
| 319 | { | ||
| 320 | ...comment, | ||
| 321 | id: 38, | ||
| 322 | body: "inline", | ||
| 323 | path: "file.ts", | ||
| 324 | position: 1, | ||
| 325 | original_position: 1, | ||
| 326 | commit_id: "head", | ||
| 327 | original_commit_id: "head", | ||
| 328 | resolver: null, | ||
| 329 | }, | ||
| 330 | ], | ||
| 331 | "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], | ||
| 332 | "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], | ||
| 333 | "repos/maria/project/issues/comments/38/reactions": [ | ||
| 334 | { content: "rocket", user: { login: "reviewer" } }, | ||
| 335 | ], | ||
| 336 | }; | ||
| 337 | const writes: ForgejoCli.ForgejoApiInput[] = []; | ||
| 338 | let reactionReads = 0; | ||
| 339 | return Effect.gen(function* () { | ||
| 340 | const provider = yield* ForgejoPullRequestProvider.make; | ||
| 341 | const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; | ||
| 342 | const activity = yield* provider.getChangeRequestActivity(input); | ||
| 343 | assert.deepStrictEqual( | ||
| 344 | activity.comments.map((entry) => ({ | ||
| 345 | id: entry.id, | ||
| 346 | kind: entry.kind, | ||
| 347 | reactions: entry.reactions, | ||
| 348 | })), | ||
| 349 | [ | ||
| 350 | { | ||
| 351 | id: "12", | ||
| 352 | kind: "issue-comment", | ||
| 353 | reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | id: "review:8", | ||
| 357 | kind: "review", | ||
| 358 | reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], | ||
| 359 | }, | ||
| 360 | { | ||
| 361 | id: "38", | ||
| 362 | kind: "review-comment", | ||
| 363 | reactions: [ | ||
| 364 | { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, | ||
| 365 | ], | ||
| 366 | }, | ||
| 367 | ], | ||
| 368 | ); | ||
| 369 | const inlineComment = activity.comments[2]; | ||
| 370 | assert.ok(inlineComment); | ||
| 371 | assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); | ||
| 372 | for (const reacted of [true, false]) { | ||
| 373 | yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); | ||
| 374 | yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); | ||
| 375 | } | ||
| 376 | assert.deepStrictEqual( | ||
| 377 | writes.map(({ path, method, body }) => ({ path, method, body })), | ||
| 378 | [ | ||
| 379 | { | ||
| 380 | path: "repos/maria/project/issues/comments/37/reactions", | ||
| 381 | method: "POST", | ||
| 382 | body: { content: "heart" }, | ||
| 383 | }, | ||
| 384 | { | ||
| 385 | path: "repos/maria/project/issues/comments/38/reactions", | ||
| 386 | method: "POST", | ||
| 387 | body: { content: "rocket" }, | ||
| 388 | }, | ||
| 389 | { | ||
| 390 | path: "repos/maria/project/issues/comments/37/reactions", | ||
| 391 | method: "DELETE", | ||
| 392 | body: { content: "heart" }, | ||
| 393 | }, | ||
| 394 | { | ||
| 395 | path: "repos/maria/project/issues/comments/38/reactions", | ||
| 396 | method: "DELETE", | ||
| 397 | body: { content: "rocket" }, | ||
| 398 | }, | ||
| 399 | ], | ||
| 400 | ); | ||
| 401 | const missing = yield* provider | ||
| 402 | .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) | ||
| 403 | .pipe(Effect.result); | ||
| 404 | assert.strictEqual(missing._tag, "Failure"); | ||
| 405 | if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); | ||
| 406 | assert.strictEqual(writes.length, 4); | ||
| 407 | responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( | ||
| 408 | { length: 501 }, | ||
| 409 | (_, index) => ({ | ||
| 410 | ...comment, | ||
| 411 | id: 1000 + index, | ||
| 412 | path: "file.ts", | ||
| 413 | position: 1, | ||
| 414 | original_position: 1, | ||
| 415 | commit_id: "head", | ||
| 416 | original_commit_id: "head", | ||
| 417 | resolver: null, | ||
| 418 | }), | ||
| 419 | ); | ||
| 420 | for (let index = 0; index < 500; index++) { | ||
| 421 | responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; | ||
| 422 | } | ||
| 423 | reactionReads = 0; | ||
| 424 | const bounded = yield* provider.getChangeRequestActivity(input); | ||
| 425 | assert.strictEqual(bounded.reviewThreads.length, 500); | ||
| 426 | assert.strictEqual( | ||
| 427 | bounded.comments.filter((entry) => entry.kind === "review-comment").length, | ||
| 428 | 500, | ||
| 429 | ); | ||
| 430 | assert.strictEqual(bounded.commentsTruncated, true); | ||
| 431 | assert.strictEqual(reactionReads, 502); | ||
| 432 | }).pipe( | ||
| 433 | Effect.provide( | ||
| 434 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 435 | api: (input) => { | ||
| 436 | if (input.method) { | ||
| 437 | writes.push(input); | ||
| 438 | return Effect.succeed(processOutput("{}")); | ||
| 439 | } | ||
| 440 | const path = input.path.split("?")[0]!; | ||
| 441 | if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; | ||
| 442 | assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); | ||
| 443 | const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); | ||
| 444 | return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( | ||
| 445 | Effect.orDie, | ||
| 446 | Effect.map(processOutput), | ||
| 447 | ); | ||
| 448 | }, | ||
| 449 | }), | ||
| 450 | ), | ||
| 451 | ); | ||
| 452 | }, | ||
| 453 | ); | ||
| 454 | |||
| 455 | it.effect("discovers Forgejo accounts and retains the server port", () => | ||
| 456 | Effect.gen(function* () { | ||
| 457 | const auth = ForgejoSourceControlProvider.discovery.parseAuth( | ||
| 458 | processOutput( | ||
| 459 | yield* encodeJsonEffect([ | ||
| 460 | { | ||
| 461 | name: "work", | ||
| 462 | url: "http://forgejo.local:3000", | ||
| 463 | ssh_host: "git.forgejo.local", | ||
| 464 | user: "maria", | ||
| 465 | default: "true", | ||
| 466 | valid: "true", | ||
| 467 | }, | ||
| 468 | ]), | ||
| 469 | ), | ||
| 470 | ); | ||
| 471 | assert.deepStrictEqual( | ||
| 472 | firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), | ||
| 473 | Option.some("tea version 0.16.0"), | ||
| 474 | ); | ||
| 475 | assert.strictEqual(auth.status, "authenticated"); | ||
| 476 | assert.deepStrictEqual(auth.account, Option.some("maria")); | ||
| 477 | assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); | ||
| 478 | const revoked = ForgejoSourceControlProvider.discovery.parseAuth( | ||
| 479 | processOutput( | ||
| 480 | encodeJson([ | ||
| 481 | { | ||
| 482 | name: "work", | ||
| 483 | url: "http://forgejo.local:3000", | ||
| 484 | user: "maria", | ||
| 485 | default: "true", | ||
| 486 | valid: "false", | ||
| 487 | }, | ||
| 488 | ]), | ||
| 489 | ), | ||
| 490 | ); | ||
| 491 | assert.strictEqual(revoked.status, "unauthenticated"); | ||
| 492 | const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ | ||
| 493 | cwd: "/repo", | ||
| 494 | context: { | ||
| 495 | provider: { | ||
| 496 | kind: "unknown", | ||
| 497 | name: "git.forgejo.local", | ||
| 498 | baseUrl: "https://git.forgejo.local", | ||
| 499 | }, | ||
| 500 | remoteName: "origin", | ||
| 501 | remoteUrl: "git@git.forgejo.local:maria/project.git", | ||
| 502 | }, | ||
| 503 | auth: processOutput( | ||
| 504 | yield* encodeJsonEffect([ | ||
| 505 | { | ||
| 506 | name: "work", | ||
| 507 | url: "http://forgejo.local:3000", | ||
| 508 | ssh_host: "git.forgejo.local", | ||
| 509 | user: "maria", | ||
| 510 | default: "true", | ||
| 511 | }, | ||
| 512 | ]), | ||
| 513 | ), | ||
| 514 | }); | ||
| 515 | assert.deepStrictEqual(refined, { | ||
| 516 | kind: "forgejo", | ||
| 517 | name: "Forgejo / Gitea", | ||
| 518 | baseUrl: "http://forgejo.local:3000", | ||
| 519 | }); | ||
| 520 | }), | ||
| 521 | ); | ||
| 522 | |||
| 523 | it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => | ||
| 524 | Effect.gen(function* () { | ||
| 525 | const logins = ForgejoCli.parseForgejoLogins( | ||
| 526 | yield* encodeJsonEffect([ | ||
| 527 | { | ||
| 528 | name: "one", | ||
| 529 | url: "http://forgejo.local:3000", | ||
| 530 | ssh_host: "forgejo.local", | ||
| 531 | user: "maria", | ||
| 532 | default: "true", | ||
| 533 | }, | ||
| 534 | { | ||
| 535 | name: "two", | ||
| 536 | url: "http://forgejo.local:4000", | ||
| 537 | ssh_host: "forgejo.local", | ||
| 538 | user: "maria", | ||
| 539 | default: "false", | ||
| 540 | }, | ||
| 541 | ]), | ||
| 542 | ); | ||
| 543 | const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); | ||
| 544 | assert.isNotNull(remote); | ||
| 545 | assert.deepStrictEqual( | ||
| 546 | ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), | ||
| 547 | remote, | ||
| 548 | ); | ||
| 549 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); | ||
| 550 | assert.strictEqual( | ||
| 551 | ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, | ||
| 552 | "two", | ||
| 553 | ); | ||
| 554 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); | ||
| 555 | const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); | ||
| 556 | assert.isNotNull(alias); | ||
| 557 | assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); | ||
| 558 | const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ | ||
| 559 | cwd: "/repo", | ||
| 560 | context: { | ||
| 561 | provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, | ||
| 562 | remoteName: "origin", | ||
| 563 | remoteUrl: "git@forgejo.local:maria/project.git", | ||
| 564 | requestedHost: "forgejo.local:4000", | ||
| 565 | }, | ||
| 566 | auth: processOutput(yield* encodeJsonEffect(logins)), | ||
| 567 | }); | ||
| 568 | assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); | ||
| 569 | const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); | ||
| 570 | assert.isNotNull(https); | ||
| 571 | assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); | ||
| 572 | const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); | ||
| 573 | assert.strictEqual( | ||
| 574 | ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, | ||
| 575 | "two", | ||
| 576 | ); | ||
| 577 | const mounted = logins.map((login) => ({ | ||
| 578 | ...login, | ||
| 579 | url: `http://forgejo.local:4000/${login.name}`, | ||
| 580 | })); | ||
| 581 | assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); | ||
| 582 | }), | ||
| 583 | ); | ||
| 584 | |||
| 585 | it.effect("rejects HTTP failures even when tea exits successfully", () => | ||
| 586 | Effect.gen(function* () { | ||
| 587 | const cli = yield* ForgejoCli.make; | ||
| 588 | const result = yield* cli | ||
| 589 | .api({ | ||
| 590 | cwd: "/repo", | ||
| 591 | repository: "http://forgejo.local:3000/maria/project", | ||
| 592 | path: "repos/maria/project/pulls/42", | ||
| 593 | method: "PATCH", | ||
| 594 | body: { state: "closed" }, | ||
| 595 | }) | ||
| 596 | .pipe(Effect.result); | ||
| 597 | assert.strictEqual(result._tag, "Failure"); | ||
| 598 | if (result._tag === "Failure") | ||
| 599 | assert.strictEqual( | ||
| 600 | result.failure.detail, | ||
| 601 | "Forgejo repository or pull request was not found.", | ||
| 602 | ); | ||
| 603 | }).pipe( | ||
| 604 | Effect.provideService( | ||
| 605 | FileSystem.FileSystem, | ||
| 606 | FileSystem.makeNoop({ | ||
| 607 | exists: () => Effect.succeed(false), | ||
| 608 | }), | ||
| 609 | ), | ||
| 610 | Effect.provideService( | ||
| 611 | HttpClient.HttpClient, | ||
| 612 | HttpClient.make(() => { | ||
| 613 | throw new Error("tea must handle its own HTTP request"); | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | Effect.provide( | ||
| 617 | TestSourceControlHost.layer({ | ||
| 618 | process: { | ||
| 619 | run: (input) => { | ||
| 620 | if (input.args[0] === "api") { | ||
| 621 | assert.strictEqual(input.stdin, '{"state":"closed"}'); | ||
| 622 | assert.include(input.args, "work"); | ||
| 623 | assert.include( | ||
| 624 | input.args, | ||
| 625 | "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", | ||
| 626 | ); | ||
| 627 | } | ||
| 628 | return Effect.succeed( | ||
| 629 | input.args[0] === "login" | ||
| 630 | ? processOutput( | ||
| 631 | encodeJson([ | ||
| 632 | { | ||
| 633 | name: "work", | ||
| 634 | url: "http://forgejo.local:3000", | ||
| 635 | ssh_host: "forgejo.local", | ||
| 636 | user: "maria", | ||
| 637 | default: "true", | ||
| 638 | }, | ||
| 639 | ]), | ||
| 640 | ) | ||
| 641 | : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), | ||
| 642 | ); | ||
| 643 | }, | ||
| 644 | }, | ||
| 645 | }), | ||
| 646 | ), | ||
| 647 | ), | ||
| 648 | ); | ||
| 649 | |||
| 650 | it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => | ||
| 651 | Effect.gen(function* () { | ||
| 652 | const cli = yield* ForgejoCli.make; | ||
| 653 | const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); | ||
| 654 | assert.strictEqual(viewer.stdout, "[]"); | ||
| 655 | const mountedRepository = yield* cli.resolveRepository({ | ||
| 656 | cwd: "/upstream-only", | ||
| 657 | host: "code.test", | ||
| 658 | repository: "maria/project", | ||
| 659 | }); | ||
| 660 | assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); | ||
| 661 | assert.strictEqual(mountedRepository.repository, "maria/project"); | ||
| 662 | for (const path of [ | ||
| 663 | "repos/forgejo/maria/project/pulls?state=open", | ||
| 664 | "repos/forgejo/maria/project", | ||
| 665 | "repos/reviewer/project/contents/file.ts", | ||
| 666 | ]) { | ||
| 667 | const result = yield* cli.api({ | ||
| 668 | cwd: "/repo", | ||
| 669 | repository: "forgejo/maria/project", | ||
| 670 | context: { | ||
| 671 | provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, | ||
| 672 | remoteName: "origin", | ||
| 673 | remoteUrl: "https://code.test/forgejo/maria/project.git", | ||
| 674 | }, | ||
| 675 | path, | ||
| 676 | }); | ||
| 677 | assert.strictEqual(result.stdout, "[]"); | ||
| 678 | } | ||
| 679 | const sameOwnerAsMount = yield* cli.resolveRepository({ | ||
| 680 | cwd: "/repo", | ||
| 681 | repository: "forgejo/project", | ||
| 682 | context: { | ||
| 683 | provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, | ||
| 684 | remoteName: "origin", | ||
| 685 | remoteUrl: "ssh://git@code.test/forgejo/project.git", | ||
| 686 | }, | ||
| 687 | }); | ||
| 688 | assert.strictEqual(sameOwnerAsMount.command, "tea"); | ||
| 689 | assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); | ||
| 690 | }).pipe( | ||
| 691 | Effect.provideService( | ||
| 692 | FileSystem.FileSystem, | ||
| 693 | FileSystem.makeNoop({ | ||
| 694 | exists: () => Effect.succeed(true), | ||
| 695 | readFileString: () => | ||
| 696 | Effect.succeed( | ||
| 697 | encodeJson({ | ||
| 698 | hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, | ||
| 699 | }), | ||
| 700 | ), | ||
| 701 | }), | ||
| 702 | ), | ||
| 703 | Effect.provideService( | ||
| 704 | HttpClient.HttpClient, | ||
| 705 | HttpClient.make(() => { | ||
| 706 | throw new Error("tea must handle its own HTTP request"); | ||
| 707 | }), | ||
| 708 | ), | ||
| 709 | Effect.provide( | ||
| 710 | TestSourceControlHost.layer({ | ||
| 711 | process: { | ||
| 712 | run: (input) => { | ||
| 713 | if (input.command === "git") | ||
| 714 | return Effect.succeed( | ||
| 715 | processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), | ||
| 716 | ); | ||
| 717 | if (input.args[0] === "login") | ||
| 718 | return Effect.succeed( | ||
| 719 | processOutput( | ||
| 720 | encodeJson([ | ||
| 721 | { | ||
| 722 | name: "mounted", | ||
| 723 | url: "https://code.test/forgejo", | ||
| 724 | ssh_host: "code.test", | ||
| 725 | user: "maria", | ||
| 726 | default: "true", | ||
| 727 | }, | ||
| 728 | ]), | ||
| 729 | ), | ||
| 730 | ); | ||
| 731 | const supported = [ | ||
| 732 | "https://code.test/forgejo/api/v1/user", | ||
| 733 | "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", | ||
| 734 | "https://code.test/forgejo/api/v1/repos/maria/project", | ||
| 735 | "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", | ||
| 736 | ]; | ||
| 737 | if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); | ||
| 738 | assert.strictEqual(input.command, "tea"); | ||
| 739 | return Effect.succeed( | ||
| 740 | supported.includes(input.args.at(-1) ?? "") | ||
| 741 | ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) | ||
| 742 | : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), | ||
| 743 | ); | ||
| 744 | }, | ||
| 745 | }, | ||
| 746 | }), | ||
| 747 | ), | ||
| 748 | ), | ||
| 749 | ); | ||
| 750 | |||
| 751 | it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { | ||
| 752 | const commands: string[] = []; | ||
| 753 | const requests: string[] = []; | ||
| 754 | return Effect.gen(function* () { | ||
| 755 | const cli = yield* ForgejoCli.make; | ||
| 756 | for (const remoteUrl of [ | ||
| 757 | "http://forgejo.local:3000/maria/project.git", | ||
| 758 | "ssh://git@ssh.forgejo.local:2222/maria/project.git", | ||
| 759 | "ssh://git@forgejo.local:2222/maria/project.git", | ||
| 760 | ]) { | ||
| 761 | const result = yield* cli.api({ | ||
| 762 | cwd: "/repo", | ||
| 763 | repository: "maria/project", | ||
| 764 | context: { | ||
| 765 | provider: { | ||
| 766 | kind: "forgejo", | ||
| 767 | name: "Forgejo", | ||
| 768 | baseUrl: "http://forgejo.local:3000", | ||
| 769 | }, | ||
| 770 | remoteName: "origin", | ||
| 771 | remoteUrl, | ||
| 772 | requestedHost: "forgejo.local:3000", | ||
| 773 | }, | ||
| 774 | path: "repos/maria/project/issues/42/comments", | ||
| 775 | method: "POST", | ||
| 776 | body: { body: "verified through fj" }, | ||
| 777 | }); | ||
| 778 | assert.strictEqual(result.stdout, '{"id":99}'); | ||
| 779 | } | ||
| 780 | assert.deepStrictEqual(commands, ["fj"]); | ||
| 781 | assert.deepStrictEqual(requests, [ | ||
| 782 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 783 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 784 | "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", | ||
| 785 | ]); | ||
| 786 | const viewer = yield* cli.api({ | ||
| 787 | cwd: "/no-remotes", | ||
| 788 | host: "forgejo.local:3000", | ||
| 789 | path: "user", | ||
| 790 | }); | ||
| 791 | assert.strictEqual(viewer.stdout, '{"login":"maria"}'); | ||
| 792 | assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); | ||
| 793 | const upstreamViewer = yield* cli.api({ | ||
| 794 | cwd: "/upstream-only", | ||
| 795 | host: "forgejo.local:3000", | ||
| 796 | path: "user", | ||
| 797 | }); | ||
| 798 | assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); | ||
| 799 | assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); | ||
| 800 | const upstreamRepository = yield* cli.resolveRepository({ | ||
| 801 | cwd: "/upstream-only", | ||
| 802 | host: "forgejo.local:3000", | ||
| 803 | repository: "maria/project", | ||
| 804 | }); | ||
| 805 | assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); | ||
| 806 | assert.strictEqual(upstreamRepository.repository, "maria/project"); | ||
| 807 | const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); | ||
| 808 | assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); | ||
| 809 | assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); | ||
| 810 | }).pipe( | ||
| 811 | Effect.provideService( | ||
| 812 | FileSystem.FileSystem, | ||
| 813 | FileSystem.makeNoop({ | ||
| 814 | exists: () => Effect.succeed(true), | ||
| 815 | readFileString: () => | ||
| 816 | Effect.succeed( | ||
| 817 | encodeJson({ | ||
| 818 | hosts: { | ||
| 819 | "forgejo.local:3000": { type: "Application", token: "test-token" }, | ||
| 820 | "forgejo.local:4000": { type: "Application", token: "other-token" }, | ||
| 821 | }, | ||
| 822 | aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, | ||
| 823 | }), | ||
| 824 | ), | ||
| 825 | }), | ||
| 826 | ), | ||
| 827 | Effect.provideService( | ||
| 828 | HttpClient.HttpClient, | ||
| 829 | HttpClient.make((request) => { | ||
| 830 | requests.push(request.url); | ||
| 831 | if (request.url.endsWith("/user")) { | ||
| 832 | assert.strictEqual(request.method, "GET"); | ||
| 833 | assert.strictEqual(request.headers.authorization, "token test-token"); | ||
| 834 | return Effect.succeed( | ||
| 835 | HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), | ||
| 836 | ); | ||
| 837 | } | ||
| 838 | assert.strictEqual(request.method, "POST"); | ||
| 839 | assert.strictEqual(request.headers.authorization, "token test-token"); | ||
| 840 | assert.strictEqual(request.body._tag, "Uint8Array"); | ||
| 841 | if (request.body._tag === "Uint8Array") | ||
| 842 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { | ||
| 843 | body: "verified through fj", | ||
| 844 | }); | ||
| 845 | return Effect.succeed( | ||
| 846 | HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), | ||
| 847 | ); | ||
| 848 | }), | ||
| 849 | ), | ||
| 850 | Effect.provide( | ||
| 851 | TestSourceControlHost.layer({ | ||
| 852 | process: { | ||
| 853 | run: (input) => { | ||
| 854 | commands.push(input.command); | ||
| 855 | if (input.command === "git") | ||
| 856 | return Effect.succeed( | ||
| 857 | input.cwd === "/no-remotes" | ||
| 858 | ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) | ||
| 859 | : processOutput( | ||
| 860 | `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, | ||
| 861 | ), | ||
| 862 | ); | ||
| 863 | assert.strictEqual(input.command, "fj"); | ||
| 864 | assert.deepStrictEqual(input.args, [ | ||
| 865 | "--host", | ||
| 866 | input.cwd === "/no-remotes" | ||
| 867 | ? "https://forgejo.local:3000" | ||
| 868 | : "http://forgejo.local:3000", | ||
| 869 | "whoami", | ||
| 870 | ]); | ||
| 871 | return Effect.succeed(processOutput("")); | ||
| 872 | }, | ||
| 873 | }, | ||
| 874 | }), | ||
| 875 | ), | ||
| 876 | ); | ||
| 877 | }); | ||
| 878 | |||
| 879 | it.effect("loads later fj review pages when the server caps pages below the requested size", () => { | ||
| 880 | const pages: number[] = []; | ||
| 881 | let issueCommentRequests = 0; | ||
| 882 | return Effect.gen(function* () { | ||
| 883 | const cli = yield* ForgejoCli.make; | ||
| 884 | const provider = yield* ForgejoPullRequestProvider.make.pipe( | ||
| 885 | Effect.provideService(ForgejoCli.ForgejoCli, cli), | ||
| 886 | ); | ||
| 887 | const activity = yield* provider.getChangeRequestActivity({ | ||
| 888 | cwd: "/repo", | ||
| 889 | repository: "maria/project", | ||
| 890 | host: "forgejo.test", | ||
| 891 | number: 42, | ||
| 892 | }); | ||
| 893 | assert.strictEqual(activity.commentCount, 42); | ||
| 894 | assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); | ||
| 895 | assert.strictEqual(activity.commentsTruncated, false); | ||
| 896 | assert.deepStrictEqual(pages, [1, 2, 3]); | ||
| 897 | assert.strictEqual(issueCommentRequests, 1); | ||
| 898 | }).pipe( | ||
| 899 | Effect.provideService( | ||
| 900 | FileSystem.FileSystem, | ||
| 901 | FileSystem.makeNoop({ | ||
| 902 | exists: () => Effect.succeed(true), | ||
| 903 | readFileString: () => | ||
| 904 | Effect.succeed( | ||
| 905 | encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), | ||
| 906 | ), | ||
| 907 | }), | ||
| 908 | ), | ||
| 909 | Effect.provideService( | ||
| 910 | HttpClient.HttpClient, | ||
| 911 | HttpClient.make((request) => { | ||
| 912 | const url = new URL(request.url); | ||
| 913 | if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { | ||
| 914 | issueCommentRequests++; | ||
| 915 | return Effect.succeed( | ||
| 916 | HttpClientResponse.fromWeb( | ||
| 917 | request, | ||
| 918 | new Response( | ||
| 919 | encodeJson([ | ||
| 920 | { | ||
| 921 | id: 100, | ||
| 922 | body: "Unpaginated issue comment", | ||
| 923 | user: { login: "maria" }, | ||
| 924 | created_at: "2026-09-13T00:00:00Z", | ||
| 925 | }, | ||
| 926 | ]), | ||
| 927 | ), | ||
| 928 | ), | ||
| 929 | ); | ||
| 930 | } | ||
| 931 | if (url.pathname.endsWith("/reviews")) { | ||
| 932 | const page = Number(url.searchParams.get("page")); | ||
| 933 | pages.push(page); | ||
| 934 | assert.ok(page >= 1 && page <= 3); | ||
| 935 | const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ | ||
| 936 | id: (page - 1) * 20 + index + 1, | ||
| 937 | body: "Review from a capped page", | ||
| 938 | user: { login: "maria" }, | ||
| 939 | state: "COMMENT", | ||
| 940 | submitted_at: "2026-09-13T00:00:00Z", | ||
| 941 | comments_count: 0, | ||
| 942 | })); | ||
| 943 | return Effect.succeed( | ||
| 944 | HttpClientResponse.fromWeb( | ||
| 945 | request, | ||
| 946 | new Response(encodeJson(reviews), { | ||
| 947 | headers: | ||
| 948 | page === 2 | ||
| 949 | ? {} | ||
| 950 | : { | ||
| 951 | Link: | ||
| 952 | page === 1 | ||
| 953 | ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` | ||
| 954 | : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, | ||
| 955 | }, | ||
| 956 | }), | ||
| 957 | ), | ||
| 958 | ); | ||
| 959 | } | ||
| 960 | return Effect.succeed( | ||
| 961 | HttpClientResponse.fromWeb( | ||
| 962 | request, | ||
| 963 | new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), | ||
| 964 | ), | ||
| 965 | ); | ||
| 966 | }), | ||
| 967 | ), | ||
| 968 | Effect.provide( | ||
| 969 | TestSourceControlHost.layer({ | ||
| 970 | process: { | ||
| 971 | run: (input) => { | ||
| 972 | if (input.command === "git") { | ||
| 973 | assert.deepStrictEqual(input.args, ["remote", "-v"]); | ||
| 974 | return Effect.succeed( | ||
| 975 | processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), | ||
| 976 | ); | ||
| 977 | } | ||
| 978 | assert.strictEqual(input.command, "fj"); | ||
| 979 | assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); | ||
| 980 | return Effect.succeed(processOutput("")); | ||
| 981 | }, | ||
| 982 | }, | ||
| 983 | }), | ||
| 984 | ), | ||
| 985 | ); | ||
| 986 | }); | ||
| 987 | |||
| 988 | it.effect("falls back to tea when fj is missing or has no account for this server", () => | ||
| 989 | Effect.gen(function* () { | ||
| 990 | for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { | ||
| 991 | const commands: string[] = []; | ||
| 992 | yield* Effect.gen(function* () { | ||
| 993 | const cli = yield* ForgejoCli.make; | ||
| 994 | const result = yield* cli.api({ | ||
| 995 | cwd: "/repo", | ||
| 996 | repository: "https://forgejo.local:3000/maria/project", | ||
| 997 | path: "repos/maria/project/pulls", | ||
| 998 | }); | ||
| 999 | assert.strictEqual(result.stdout, "[]"); | ||
| 1000 | assert.deepStrictEqual( | ||
| 1001 | commands, | ||
| 1002 | scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], | ||
| 1003 | ); | ||
| 1004 | const viewer = yield* cli.api({ | ||
| 1005 | cwd: "/upstream-only", | ||
| 1006 | host: "forgejo.local:3000", | ||
| 1007 | path: "user", | ||
| 1008 | }); | ||
| 1009 | assert.strictEqual(viewer.stdout, "[]"); | ||
| 1010 | }).pipe( | ||
| 1011 | Effect.provideService( | ||
| 1012 | FileSystem.FileSystem, | ||
| 1013 | FileSystem.makeNoop({ | ||
| 1014 | exists: () => Effect.succeed(true), | ||
| 1015 | readFileString: () => | ||
| 1016 | Effect.succeed( | ||
| 1017 | scenario === "stale-invalid-storage" | ||
| 1018 | ? "invalid json" | ||
| 1019 | : encodeJson({ | ||
| 1020 | hosts: { | ||
| 1021 | [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { | ||
| 1022 | type: "Application", | ||
| 1023 | token: "test-token", | ||
| 1024 | }, | ||
| 1025 | }, | ||
| 1026 | }), | ||
| 1027 | ), | ||
| 1028 | }), | ||
| 1029 | ), | ||
| 1030 | Effect.provideService( | ||
| 1031 | HttpClient.HttpClient, | ||
| 1032 | HttpClient.make(() => { | ||
| 1033 | throw new Error("tea must handle its own HTTP request"); | ||
| 1034 | }), | ||
| 1035 | ), | ||
| 1036 | Effect.provide( | ||
| 1037 | TestSourceControlHost.layer({ | ||
| 1038 | process: { | ||
| 1039 | run: (input) => { | ||
| 1040 | commands.push(input.command); | ||
| 1041 | if (input.command === "git") | ||
| 1042 | return Effect.succeed( | ||
| 1043 | processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), | ||
| 1044 | ); | ||
| 1045 | if (input.command === "fj") | ||
| 1046 | return Effect.fail( | ||
| 1047 | new VcsProcessSpawnError({ | ||
| 1048 | operation: input.operation, | ||
| 1049 | command: input.command, | ||
| 1050 | cwd: input.cwd, | ||
| 1051 | cause: new Error("fj not found"), | ||
| 1052 | }), | ||
| 1053 | ); | ||
| 1054 | assert.strictEqual(input.command, "tea"); | ||
| 1055 | if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); | ||
| 1056 | return Effect.succeed( | ||
| 1057 | input.args[0] === "login" | ||
| 1058 | ? processOutput( | ||
| 1059 | encodeJson([ | ||
| 1060 | { | ||
| 1061 | name: "work", | ||
| 1062 | url: "https://forgejo.local:3000", | ||
| 1063 | user: "maria", | ||
| 1064 | default: "true", | ||
| 1065 | valid: "true", | ||
| 1066 | }, | ||
| 1067 | ]), | ||
| 1068 | ) | ||
| 1069 | : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), | ||
| 1070 | ); | ||
| 1071 | }, | ||
| 1072 | }, | ||
| 1073 | }), | ||
| 1074 | ), | ||
| 1075 | ); | ||
| 1076 | } | ||
| 1077 | }), | ||
| 1078 | ); | ||
| 1079 | |||
| 1080 | it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => | ||
| 1081 | Effect.gen(function* () { | ||
| 1082 | for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { | ||
| 1083 | let writes = 0; | ||
| 1084 | yield* Effect.gen(function* () { | ||
| 1085 | const cli = yield* ForgejoCli.make; | ||
| 1086 | const result = yield* cli | ||
| 1087 | .api({ | ||
| 1088 | cwd: "/repo", | ||
| 1089 | repository: "https://forgejo.local/maria/project", | ||
| 1090 | path: "repos/maria/project/issues/42/comments", | ||
| 1091 | method: "POST", | ||
| 1092 | body: { body: "only once" }, | ||
| 1093 | }) | ||
| 1094 | .pipe(Effect.result); | ||
| 1095 | assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); | ||
| 1096 | if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); | ||
| 1097 | if (result._tag === "Failure") { | ||
| 1098 | assert.strictEqual(result.failure.command, "fj"); | ||
| 1099 | assert.strictEqual(result.failure.httpStatus, status); | ||
| 1100 | } | ||
| 1101 | assert.strictEqual(writes, 1); | ||
| 1102 | }).pipe( | ||
| 1103 | Effect.provideService( | ||
| 1104 | FileSystem.FileSystem, | ||
| 1105 | FileSystem.makeNoop({ | ||
| 1106 | exists: () => Effect.succeed(true), | ||
| 1107 | readFileString: () => | ||
| 1108 | Effect.succeed( | ||
| 1109 | encodeJson({ | ||
| 1110 | hosts: { | ||
| 1111 | "forgejo.local": { type: "Application", token: "test-token" }, | ||
| 1112 | }, | ||
| 1113 | }), | ||
| 1114 | ), | ||
| 1115 | }), | ||
| 1116 | ), | ||
| 1117 | Effect.provideService( | ||
| 1118 | HttpClient.HttpClient, | ||
| 1119 | HttpClient.make((request) => { | ||
| 1120 | writes++; | ||
| 1121 | assert.strictEqual( | ||
| 1122 | request.url, | ||
| 1123 | "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", | ||
| 1124 | ); | ||
| 1125 | return Effect.succeed( | ||
| 1126 | HttpClientResponse.fromWeb( | ||
| 1127 | request, | ||
| 1128 | new Response(status < 300 ? null : "", { | ||
| 1129 | status, | ||
| 1130 | headers: { location: "https://other.local/" }, | ||
| 1131 | }), | ||
| 1132 | ), | ||
| 1133 | ); | ||
| 1134 | }), | ||
| 1135 | ), | ||
| 1136 | Effect.provide( | ||
| 1137 | TestSourceControlHost.layer({ | ||
| 1138 | process: { | ||
| 1139 | run: (input) => { | ||
| 1140 | assert.strictEqual( | ||
| 1141 | input.command, | ||
| 1142 | "fj", | ||
| 1143 | "a failed mutation must never switch accounts or CLI", | ||
| 1144 | ); | ||
| 1145 | return Effect.succeed(processOutput("")); | ||
| 1146 | }, | ||
| 1147 | }, | ||
| 1148 | }), | ||
| 1149 | ), | ||
| 1150 | ); | ||
| 1151 | } | ||
| 1152 | }), | ||
| 1153 | ); | ||
| 1154 | |||
| 1155 | it.effect( | ||
| 1156 | "discovers fj first and retains configured authentication failures instead of switching accounts", | ||
| 1157 | () => | ||
| 1158 | Effect.gen(function* () { | ||
| 1159 | for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { | ||
| 1160 | const commands: string[] = []; | ||
| 1161 | yield* Effect.gen(function* () { | ||
| 1162 | const spec = yield* ForgejoSourceControlProvider.makeDiscovery; | ||
| 1163 | assert.strictEqual(spec.type, "managed-cli"); | ||
| 1164 | if (spec.type !== "managed-cli") return; | ||
| 1165 | const result = yield* spec.probe("/repo"); | ||
| 1166 | assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); | ||
| 1167 | assert.strictEqual( | ||
| 1168 | result.auth.status, | ||
| 1169 | scenario === "revoked" | ||
| 1170 | ? "unauthenticated" | ||
| 1171 | : scenario === "invalid-storage" | ||
| 1172 | ? "unknown" | ||
| 1173 | : "authenticated", | ||
| 1174 | ); | ||
| 1175 | if (scenario !== "revoked" && scenario !== "invalid-storage") | ||
| 1176 | assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); | ||
| 1177 | assert.deepStrictEqual( | ||
| 1178 | result.auth.account, | ||
| 1179 | scenario === "authenticated" || scenario === "missing" | ||
| 1180 | ? Option.some("maria") | ||
| 1181 | : Option.none(), | ||
| 1182 | ); | ||
| 1183 | assert.strictEqual( | ||
| 1184 | commands.some((command) => command.startsWith("tea ")), | ||
| 1185 | scenario === "missing", | ||
| 1186 | ); | ||
| 1187 | assert.include(commands, "fj version"); | ||
| 1188 | }).pipe( | ||
| 1189 | Effect.provide( | ||
| 1190 | Layer.mergeAll( | ||
| 1191 | Layer.mock(ForgejoCli.ForgejoCli)({ | ||
| 1192 | getAccount: (input) => { | ||
| 1193 | assert.strictEqual(scenario, "authenticated"); | ||
| 1194 | assert.deepStrictEqual(input, { | ||
| 1195 | cwd: "/repo", | ||
| 1196 | baseUrl: "http://forgejo.local:3000", | ||
| 1197 | }); | ||
| 1198 | return Effect.succeed("maria"); | ||
| 1199 | }, | ||
| 1200 | listLogins: (input) => { | ||
| 1201 | assert.strictEqual( | ||
| 1202 | input.remoteUrl, | ||
| 1203 | "http://forgejo.local:3000/maria/project.git", | ||
| 1204 | ); | ||
| 1205 | if (scenario === "invalid-storage") | ||
| 1206 | return Effect.fail( | ||
| 1207 | new ForgejoCli.ForgejoCliError({ | ||
| 1208 | command: "fj", | ||
| 1209 | cwd: input.cwd, | ||
| 1210 | reason: "authentication", | ||
| 1211 | detail: "fj authentication storage is invalid.", | ||
| 1212 | }), | ||
| 1213 | ); | ||
| 1214 | return Effect.succeed([ | ||
| 1215 | { | ||
| 1216 | name: "forgejo.local:3000", | ||
| 1217 | url: "http://forgejo.local:3000", | ||
| 1218 | user: "", | ||
| 1219 | default: "false", | ||
| 1220 | }, | ||
| 1221 | ]); | ||
| 1222 | }, | ||
| 1223 | }), | ||
| 1224 | TestSourceControlHost.layer({ | ||
| 1225 | process: { | ||
| 1226 | run: (input) => { | ||
| 1227 | commands.push(`${input.command} ${input.args.join(" ")}`); | ||
| 1228 | if (input.command === "git") | ||
| 1229 | return Effect.succeed( | ||
| 1230 | processOutput("http://forgejo.local:3000/maria/project.git\n"), | ||
| 1231 | ); | ||
| 1232 | if (input.command === "fj") { | ||
| 1233 | if (scenario === "missing") | ||
| 1234 | return Effect.fail( | ||
| 1235 | new VcsProcessSpawnError({ | ||
| 1236 | operation: input.operation, | ||
| 1237 | command: input.command, | ||
| 1238 | cwd: input.cwd, | ||
| 1239 | cause: new Error("fj not found"), | ||
| 1240 | }), | ||
| 1241 | ); | ||
| 1242 | if (input.args[0] === "version") | ||
| 1243 | return Effect.succeed(processOutput("fj 0.10.0")); | ||
| 1244 | if (scenario === "invalid-storage") { | ||
| 1245 | assert.deepStrictEqual(input.args, ["auth", "list"]); | ||
| 1246 | return Effect.succeed(processOutput("")); | ||
| 1247 | } | ||
| 1248 | assert.deepStrictEqual(input.args, [ | ||
| 1249 | "--host", | ||
| 1250 | "http://forgejo.local:3000", | ||
| 1251 | "whoami", | ||
| 1252 | ]); | ||
| 1253 | return Effect.succeed( | ||
| 1254 | processOutput("", { | ||
| 1255 | exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), | ||
| 1256 | }), | ||
| 1257 | ); | ||
| 1258 | } | ||
| 1259 | assert.strictEqual(input.command, "tea"); | ||
| 1260 | return Effect.succeed( | ||
| 1261 | input.args[0] === "--version" | ||
| 1262 | ? processOutput("tea version 0.16.0") | ||
| 1263 | : processOutput( | ||
| 1264 | encodeJson([ | ||
| 1265 | { | ||
| 1266 | name: "work", | ||
| 1267 | url: "http://forgejo.local:3000", | ||
| 1268 | user: "maria", | ||
| 1269 | default: "true", | ||
| 1270 | valid: "true", | ||
| 1271 | }, | ||
| 1272 | ]), | ||
| 1273 | ), | ||
| 1274 | ); | ||
| 1275 | }, | ||
| 1276 | }, | ||
| 1277 | }), | ||
| 1278 | ), | ||
| 1279 | ), | ||
| 1280 | ); | ||
| 1281 | } | ||
| 1282 | }), | ||
| 1283 | ); | ||
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts created+387| ... | @@ -0,0 +1,387 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { SourceControlProviderError } from "@t3tools/contracts"; | ||
| 6 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 7 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 8 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 9 | import { | ||
| 10 | providerAuth, | ||
| 11 | probeSourceControlProvider, | ||
| 12 | type SourceControlCliDiscoverySpec, | ||
| 13 | type SourceControlManagedCliDiscoverySpec, | ||
| 14 | } from "@t3tools/source-control-core/server/discovery"; | ||
| 15 | import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; | ||
| 16 | |||
| 17 | const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); | ||
| 18 | |||
| 19 | export const discovery = { | ||
| 20 | type: "cli", | ||
| 21 | kind: "forgejo", | ||
| 22 | label: "Forgejo / Gitea", | ||
| 23 | executable: "tea", | ||
| 24 | versionArgs: ["--version"], | ||
| 25 | authArgs: ["login", "status", "--output", "json"], | ||
| 26 | remoteRefinementArgs: ["login", "list", "--output", "json"], | ||
| 27 | parseAuth: (input) => { | ||
| 28 | const logins = ForgejoCli.parseForgejoLogins(input.stdout); | ||
| 29 | const login = logins.find((entry) => entry.default === "true") ?? logins[0]; | ||
| 30 | return login | ||
| 31 | ? providerAuth({ | ||
| 32 | status: login.valid === "true" ? "authenticated" : "unauthenticated", | ||
| 33 | account: login.user, | ||
| 34 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 35 | }) | ||
| 36 | : providerAuth({ | ||
| 37 | status: "unauthenticated", | ||
| 38 | detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.", | ||
| 39 | }); | ||
| 40 | }, | ||
| 41 | refineUnknownRemote: (input) => { | ||
| 42 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); | ||
| 43 | const login = | ||
| 44 | remote && | ||
| 45 | ForgejoCli.matchForgejoLogin( | ||
| 46 | ForgejoCli.parseForgejoLogins(input.auth.stdout), | ||
| 47 | remote, | ||
| 48 | input.context.requestedHost, | ||
| 49 | ); | ||
| 50 | return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null; | ||
| 51 | }, | ||
| 52 | installHint: | ||
| 53 | "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", | ||
| 54 | } satisfies SourceControlCliDiscoverySpec; | ||
| 55 | |||
| 56 | export const makeDiscovery = Effect.gen(function* () { | ||
| 57 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 58 | const { process } = yield* SourceControlHost.SourceControlHost; | ||
| 59 | const listLogins = cli.listLogins; | ||
| 60 | if (!listLogins) return discovery; | ||
| 61 | return { | ||
| 62 | type: "managed-cli", | ||
| 63 | kind: "forgejo", | ||
| 64 | label: discovery.label, | ||
| 65 | installHint: discovery.installHint, | ||
| 66 | probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { | ||
| 67 | const remoteUrl = yield* process | ||
| 68 | .run({ | ||
| 69 | operation: "source-control.discovery.remote", | ||
| 70 | command: "git", | ||
| 71 | args: ["remote", "get-url", "origin"], | ||
| 72 | cwd, | ||
| 73 | allowNonZeroExit: true, | ||
| 74 | timeoutMs: 5_000, | ||
| 75 | maxOutputBytes: 8_000, | ||
| 76 | }) | ||
| 77 | .pipe( | ||
| 78 | Effect.map((result) => result.stdout.trim()), | ||
| 79 | Effect.orElseSucceed(() => ""), | ||
| 80 | ); | ||
| 81 | const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl })); | ||
| 82 | const logins = Result.isSuccess(credentials) ? credentials.success : []; | ||
| 83 | const remote = ForgejoCli.parseForgejoRemote(remoteUrl); | ||
| 84 | const login = | ||
| 85 | (remote && ForgejoCli.matchForgejoLogin(logins, remote)) || | ||
| 86 | logins.find((entry) => entry.default === "true") || | ||
| 87 | logins[0]; | ||
| 88 | const fj = yield* probeSourceControlProvider({ | ||
| 89 | cwd, | ||
| 90 | process, | ||
| 91 | spec: { | ||
| 92 | ...discovery, | ||
| 93 | executable: "fj", | ||
| 94 | versionArgs: ["version"], | ||
| 95 | authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"], | ||
| 96 | parseAuth: (result) => | ||
| 97 | Result.isFailure(credentials) | ||
| 98 | ? providerAuth({ | ||
| 99 | status: "unknown", | ||
| 100 | detail: "Could not read fj authentication storage. Authenticate again with fj.", | ||
| 101 | }) | ||
| 102 | : login && result.exitCode === 0 | ||
| 103 | ? providerAuth({ | ||
| 104 | status: "authenticated", | ||
| 105 | account: login.user, | ||
| 106 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 107 | }) | ||
| 108 | : providerAuth({ | ||
| 109 | status: "unauthenticated", | ||
| 110 | detail: | ||
| 111 | "Authenticate this server with `fj --host <server-url> auth add-token`.", | ||
| 112 | }), | ||
| 113 | }, | ||
| 114 | }); | ||
| 115 | // A configured fj account owns its requests, including authentication errors. | ||
| 116 | if (fj.status === "available" && (login || Result.isFailure(credentials))) { | ||
| 117 | if (login && fj.auth.status === "authenticated" && cli.getAccount) { | ||
| 118 | const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result); | ||
| 119 | return { | ||
| 120 | ...fj, | ||
| 121 | auth: Result.isSuccess(account) | ||
| 122 | ? providerAuth({ | ||
| 123 | status: "authenticated", | ||
| 124 | account: account.success, | ||
| 125 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 126 | }) | ||
| 127 | : providerAuth({ | ||
| 128 | status: "unknown", | ||
| 129 | detail: account.failure.detail, | ||
| 130 | host: ForgejoCli.parseForgejoRemote(login.url)?.host, | ||
| 131 | }), | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | return fj; | ||
| 135 | } | ||
| 136 | const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); | ||
| 137 | return tea.status === "available" || fj.status === "missing" ? tea : fj; | ||
| 138 | }), | ||
| 139 | refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")( | ||
| 140 | function* (input: { | ||
| 141 | readonly cwd: string; | ||
| 142 | readonly context: SourceControlProvider.SourceControlProviderContext; | ||
| 143 | }) { | ||
| 144 | const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); | ||
| 145 | if (!remote) return null; | ||
| 146 | for (const command of ["fj", "tea"] as const) { | ||
| 147 | const logins = yield* listLogins({ | ||
| 148 | cwd: input.cwd, | ||
| 149 | command, | ||
| 150 | remoteUrl: input.context.remoteUrl, | ||
| 151 | }).pipe(Effect.orElseSucceed(() => [])); | ||
| 152 | const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); | ||
| 153 | if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url }; | ||
| 154 | } | ||
| 155 | return null; | ||
| 156 | }, | ||
| 157 | ), | ||
| 158 | } satisfies SourceControlManagedCliDiscoverySpec; | ||
| 159 | }); | ||
| 160 | |||
| 161 | const RepositorySchema = Schema.Struct({ | ||
| 162 | full_name: Schema.String, | ||
| 163 | clone_url: Schema.String, | ||
| 164 | ssh_url: Schema.String, | ||
| 165 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 166 | }); | ||
| 167 | const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ | ||
| 168 | nameWithOwner: raw.full_name, | ||
| 169 | url: raw.clone_url, | ||
| 170 | sshUrl: raw.ssh_url, | ||
| 171 | }); | ||
| 172 | const repositoryPath = (repository: string) => | ||
| 173 | `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 174 | |||
| 175 | export const make = Effect.gen(function* () { | ||
| 176 | const cli = yield* ForgejoCli.ForgejoCli; | ||
| 177 | const fs = yield* FileSystem.FileSystem; | ||
| 178 | const { process } = yield* SourceControlHost.SourceControlHost; | ||
| 179 | const request = <S extends Schema.Codec<unknown, unknown, never, never>>( | ||
| 180 | input: ForgejoCli.ForgejoApiInput, | ||
| 181 | schema: S, | ||
| 182 | ) => | ||
| 183 | cli.api(input).pipe( | ||
| 184 | Effect.flatMap((result) => | ||
| 185 | Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe( | ||
| 186 | Effect.mapError( | ||
| 187 | (cause) => | ||
| 188 | new ForgejoCli.ForgejoCliError({ | ||
| 189 | command: "tea", | ||
| 190 | cwd: input.cwd, | ||
| 191 | detail: "Forgejo API returned an invalid response.", | ||
| 192 | reason: "invalid-response", | ||
| 193 | cause, | ||
| 194 | }), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | const mapError = (operation: string, cwd: string) => | ||
| 200 | Effect.mapError( | ||
| 201 | (cause: unknown) => | ||
| 202 | new SourceControlProviderError({ | ||
| 203 | provider: "forgejo", | ||
| 204 | operation, | ||
| 205 | cwd, | ||
| 206 | ...(isForgejoCliError(cause) ? { command: cause.command } : {}), | ||
| 207 | detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.", | ||
| 208 | cause, | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* ( | ||
| 212 | input: Parameters< | ||
| 213 | SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] | ||
| 214 | >[0], | ||
| 215 | ) { | ||
| 216 | const repo = yield* cli.resolveRepository(input); | ||
| 217 | const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1]; | ||
| 218 | if (!number) | ||
| 219 | return yield* new ForgejoCli.ForgejoCliError({ | ||
| 220 | command: "tea", | ||
| 221 | cwd: input.cwd, | ||
| 222 | detail: "Specify a pull request number or Forgejo pull request URL.", | ||
| 223 | }); | ||
| 224 | return yield* request( | ||
| 225 | { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` }, | ||
| 226 | ForgejoPullRequestSchema, | ||
| 227 | ); | ||
| 228 | }); | ||
| 229 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 230 | kind: "forgejo", | ||
| 231 | listChangeRequests: (input) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const repo = yield* cli.resolveRepository(input); | ||
| 234 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 235 | const branch = SourceControlProvider.sourceBranch(input); | ||
| 236 | const results: ReturnType<typeof toForgejoChangeRequest>[] = []; | ||
| 237 | const limit = input.limit ?? 20; | ||
| 238 | for (let page = 1; results.length < limit; page++) { | ||
| 239 | const items = yield* request( | ||
| 240 | { | ||
| 241 | ...input, | ||
| 242 | path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`, | ||
| 243 | }, | ||
| 244 | Schema.Array(ForgejoPullRequestSchema), | ||
| 245 | ); | ||
| 246 | for (const item of items) { | ||
| 247 | if ( | ||
| 248 | item.head.ref !== branch || | ||
| 249 | (source?.repository && item.head.repo?.full_name !== source.repository) || | ||
| 250 | (source?.owner && item.head.repo?.owner.login !== source.owner) | ||
| 251 | ) | ||
| 252 | continue; | ||
| 253 | const normalized = toForgejoChangeRequest(item); | ||
| 254 | if (input.state === "all" || normalized.state === input.state) results.push(normalized); | ||
| 255 | } | ||
| 256 | if (items.length === 0) break; | ||
| 257 | } | ||
| 258 | return results.slice(0, limit); | ||
| 259 | }).pipe(mapError("listChangeRequests", input.cwd)), | ||
| 260 | getChangeRequest: (input) => | ||
| 261 | getPull(input).pipe( | ||
| 262 | Effect.map(toForgejoChangeRequest), | ||
| 263 | mapError("getChangeRequest", input.cwd), | ||
| 264 | ), | ||
| 265 | createChangeRequest: (input) => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const repo = yield* cli.resolveRepository(input); | ||
| 268 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 269 | const owner = source?.owner ?? source?.repository?.split("/")[0]; | ||
| 270 | const head = SourceControlProvider.sourceBranch(input); | ||
| 271 | yield* cli.api({ | ||
| 272 | ...input, | ||
| 273 | path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`, | ||
| 274 | method: "POST", | ||
| 275 | body: { | ||
| 276 | base: input.target?.refName ?? input.baseRefName, | ||
| 277 | head: owner ? `${owner}:${head}` : head, | ||
| 278 | title: input.title, | ||
| 279 | body: yield* fs.readFileString(input.bodyFile), | ||
| 280 | }, | ||
| 281 | }); | ||
| 282 | }).pipe(mapError("createChangeRequest", input.cwd)), | ||
| 283 | getRepositoryCloneUrls: (input) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const repo = yield* cli.resolveRepository(input); | ||
| 286 | return cloneUrls( | ||
| 287 | yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema), | ||
| 288 | ); | ||
| 289 | }).pipe(mapError("getRepositoryCloneUrls", input.cwd)), | ||
| 290 | createRepository: (input) => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const repo = yield* cli.resolveRepository(input); | ||
| 293 | const user = yield* request( | ||
| 294 | { ...input, path: "user" }, | ||
| 295 | Schema.Struct({ login: Schema.String }), | ||
| 296 | ); | ||
| 297 | const [owner, name] = repo.repository.split("/"); | ||
| 298 | return cloneUrls( | ||
| 299 | yield* request( | ||
| 300 | { | ||
| 301 | ...input, | ||
| 302 | path: | ||
| 303 | owner === user.login | ||
| 304 | ? "user/repos" | ||
| 305 | : `orgs/${encodeURIComponent(owner ?? "")}/repos`, | ||
| 306 | method: "POST", | ||
| 307 | body: { name, private: input.visibility === "private", auto_init: false }, | ||
| 308 | }, | ||
| 309 | RepositorySchema, | ||
| 310 | ), | ||
| 311 | ); | ||
| 312 | }).pipe(mapError("createRepository", input.cwd)), | ||
| 313 | getDefaultBranch: (input) => | ||
| 314 | Effect.gen(function* () { | ||
| 315 | const repo = yield* cli.resolveRepository(input); | ||
| 316 | return ( | ||
| 317 | (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema)) | ||
| 318 | .default_branch ?? null | ||
| 319 | ); | ||
| 320 | }).pipe(mapError("getDefaultBranch", input.cwd)), | ||
| 321 | checkoutChangeRequest: (input) => | ||
| 322 | Effect.gen(function* () { | ||
| 323 | const repo = yield* cli.resolveRepository(input); | ||
| 324 | const pull = yield* getPull(input); | ||
| 325 | if (repo.command === "fj") { | ||
| 326 | // fj checkout cannot target a repository outside the local remotes. | ||
| 327 | const urls = yield* request( | ||
| 328 | { ...input, path: repositoryPath(repo.repository) }, | ||
| 329 | RepositorySchema, | ||
| 330 | ); | ||
| 331 | const remote = input.context?.remoteUrl; | ||
| 332 | const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh; | ||
| 333 | yield* process.run({ | ||
| 334 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 335 | command: "git", | ||
| 336 | cwd: input.cwd, | ||
| 337 | args: [ | ||
| 338 | "fetch", | ||
| 339 | "--", | ||
| 340 | useSsh ? urls.ssh_url : urls.clone_url, | ||
| 341 | `refs/pull/${pull.number}/head`, | ||
| 342 | ], | ||
| 343 | }); | ||
| 344 | const branch = `pulls/${pull.number}`; | ||
| 345 | const existing = yield* process.run({ | ||
| 346 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 347 | command: "git", | ||
| 348 | cwd: input.cwd, | ||
| 349 | args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | ||
| 350 | allowNonZeroExit: true, | ||
| 351 | }); | ||
| 352 | yield* process.run({ | ||
| 353 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 354 | command: "git", | ||
| 355 | cwd: input.cwd, | ||
| 356 | args: | ||
| 357 | existing.exitCode === 0 | ||
| 358 | ? ["checkout", branch] | ||
| 359 | : ["checkout", "-b", branch, "FETCH_HEAD"], | ||
| 360 | }); | ||
| 361 | } else | ||
| 362 | yield* cli.execute({ | ||
| 363 | cwd: input.cwd, | ||
| 364 | args: [ | ||
| 365 | "pulls", | ||
| 366 | "checkout", | ||
| 367 | "--login", | ||
| 368 | repo.login, | ||
| 369 | "--repo", | ||
| 370 | repo.repository, | ||
| 371 | "--branch", | ||
| 372 | String(pull.number), | ||
| 373 | ], | ||
| 374 | }); | ||
| 375 | if (input.force) { | ||
| 376 | // tea leaves an existing PR branch at its old tip. Keep dirty files safe | ||
| 377 | // while bringing the selected branch to the PR revision we fetched. | ||
| 378 | yield* process.run({ | ||
| 379 | operation: "ForgejoSourceControlProvider.checkoutChangeRequest", | ||
| 380 | command: "git", | ||
| 381 | cwd: input.cwd, | ||
| 382 | args: ["reset", "--keep", pull.head.sha], | ||
| 383 | }); | ||
| 384 | } | ||
| 385 | }).pipe(mapError("checkoutChangeRequest", input.cwd)), | ||
| 386 | }); | ||
| 387 | }); | ||
packages/source-control-forgejo/src/server/driver.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | /** | ||
| 2 | * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through | ||
| 3 | * `fj` or `tea`. | ||
| 4 | * | ||
| 5 | * @module source-control-forgejo/server/driver | ||
| 6 | */ | ||
| 7 | import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | |||
| 10 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | ||
| 11 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | ||
| 12 | |||
| 13 | export const driver = defineSourceControlDriver({ | ||
| 14 | kind: "forgejo", | ||
| 15 | make: Effect.all({ | ||
| 16 | sourceControl: ForgejoSourceControlProvider.make, | ||
| 17 | discovery: ForgejoSourceControlProvider.makeDiscovery, | ||
| 18 | pullRequests: ForgejoPullRequestProvider.make, | ||
| 19 | }), | ||
| 20 | }); | ||
packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts created+262| ... | @@ -0,0 +1,262 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import type { | ||
| 5 | PullRequestActor, | ||
| 6 | PullRequestCheck, | ||
| 7 | PullRequestComment, | ||
| 8 | PullRequestCommit, | ||
| 9 | PullRequestReaction, | ||
| 10 | PullRequestReactionContent, | ||
| 11 | PullRequestReviewThread, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 14 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | ||
| 15 | |||
| 16 | export const ForgejoUser = Schema.Struct({ | ||
| 17 | login: Schema.String, | ||
| 18 | full_name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 19 | avatar_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 20 | }); | ||
| 21 | export const ForgejoLabel = Schema.Struct({ | ||
| 22 | id: Schema.Int, | ||
| 23 | name: Schema.String, | ||
| 24 | color: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 25 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 26 | }); | ||
| 27 | export const ForgejoRepository = Schema.Struct({ | ||
| 28 | full_name: Schema.String, | ||
| 29 | permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })), | ||
| 30 | archived: Schema.optional(Schema.Boolean), | ||
| 31 | allow_merge_commits: Schema.optional(Schema.Boolean), | ||
| 32 | allow_squash_merge: Schema.optional(Schema.Boolean), | ||
| 33 | allow_rebase: Schema.optional(Schema.Boolean), | ||
| 34 | allow_rebase_update: Schema.optional(Schema.Boolean), | ||
| 35 | }); | ||
| 36 | const Branch = Schema.Struct({ | ||
| 37 | ref: Schema.String, | ||
| 38 | sha: Schema.String, | ||
| 39 | repo: Schema.NullOr(ForgejoRepository), | ||
| 40 | }); | ||
| 41 | export const ForgejoPullRequest = Schema.Struct({ | ||
| 42 | number: Schema.Int, | ||
| 43 | title: Schema.String, | ||
| 44 | body: Schema.NullOr(Schema.String), | ||
| 45 | html_url: Schema.String, | ||
| 46 | user: Schema.NullOr(ForgejoUser), | ||
| 47 | state: Schema.String, | ||
| 48 | draft: Schema.optional(Schema.Boolean), | ||
| 49 | merged: Schema.Boolean, | ||
| 50 | mergeable: Schema.optional(Schema.Boolean), | ||
| 51 | is_locked: Schema.optional(Schema.Boolean), | ||
| 52 | head: Branch, | ||
| 53 | base: Branch, | ||
| 54 | merge_base: Schema.optional(Schema.String), | ||
| 55 | created_at: Schema.String, | ||
| 56 | updated_at: Schema.String, | ||
| 57 | closed_at: Schema.NullOr(Schema.String), | ||
| 58 | merged_at: Schema.NullOr(Schema.String), | ||
| 59 | additions: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 60 | deletions: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 61 | changed_files: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 62 | comments: Schema.optional(Schema.Int), | ||
| 63 | labels: Schema.NullOr(Schema.Array(ForgejoLabel)), | ||
| 64 | requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), | ||
| 65 | }); | ||
| 66 | export const ForgejoComment = Schema.Struct({ | ||
| 67 | id: Schema.Int, | ||
| 68 | body: Schema.String, | ||
| 69 | user: Schema.NullOr(ForgejoUser), | ||
| 70 | created_at: Schema.String, | ||
| 71 | html_url: Schema.optional(Schema.String), | ||
| 72 | }); | ||
| 73 | export const ForgejoReview = Schema.Struct({ | ||
| 74 | id: Schema.Int, | ||
| 75 | body: Schema.String, | ||
| 76 | user: Schema.NullOr(ForgejoUser), | ||
| 77 | state: Schema.String, | ||
| 78 | submitted_at: Schema.String, | ||
| 79 | html_url: Schema.optional(Schema.String), | ||
| 80 | comments_count: Schema.Int, | ||
| 81 | }); | ||
| 82 | export const ForgejoReviewComment = Schema.Struct({ | ||
| 83 | ...ForgejoComment.fields, | ||
| 84 | path: Schema.String, | ||
| 85 | position: Schema.Int, | ||
| 86 | original_position: Schema.Int, | ||
| 87 | commit_id: Schema.String, | ||
| 88 | original_commit_id: Schema.String, | ||
| 89 | resolver: Schema.NullOr(ForgejoUser), | ||
| 90 | }); | ||
| 91 | export const ForgejoCommit = Schema.Struct({ | ||
| 92 | sha: Schema.String, | ||
| 93 | author: Schema.NullOr(ForgejoUser), | ||
| 94 | commit: Schema.Struct({ | ||
| 95 | message: Schema.String, | ||
| 96 | committer: Schema.Struct({ date: Schema.String }), | ||
| 97 | }), | ||
| 98 | parents: Schema.Array(Schema.Struct({ sha: Schema.String })), | ||
| 99 | stats: Schema.optional( | ||
| 100 | Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })), | ||
| 101 | ), | ||
| 102 | }); | ||
| 103 | export const ForgejoStatus = Schema.Struct({ | ||
| 104 | context: Schema.String, | ||
| 105 | status: Schema.String, | ||
| 106 | description: Schema.NullOr(Schema.String), | ||
| 107 | target_url: Schema.NullOr(Schema.String), | ||
| 108 | updated_at: Schema.String, | ||
| 109 | }); | ||
| 110 | export const ForgejoReaction = Schema.Struct({ | ||
| 111 | content: Schema.String, | ||
| 112 | user: Schema.NullOr(ForgejoUser), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export function forgejoActor( | ||
| 116 | user: typeof ForgejoUser.Type | null | undefined, | ||
| 117 | ): PullRequestActor | null { | ||
| 118 | return user?.login | ||
| 119 | ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null } | ||
| 120 | : null; | ||
| 121 | } | ||
| 122 | |||
| 123 | function toIsoUtc(value: string): string { | ||
| 124 | return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso }); | ||
| 125 | } | ||
| 126 | |||
| 127 | export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) { | ||
| 128 | return { | ||
| 129 | number: pr.number, | ||
| 130 | title: pr.title, | ||
| 131 | url: pr.html_url, | ||
| 132 | author: forgejoActor(pr.user), | ||
| 133 | headBranch: pr.head.ref, | ||
| 134 | baseBranch: pr.base.ref, | ||
| 135 | headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null, | ||
| 136 | state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open", | ||
| 137 | isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title), | ||
| 138 | // A false value also covers drafts and unfinished or failed conflict checks. | ||
| 139 | mergeability: pr.mergeable === true ? "mergeable" : "unknown", | ||
| 140 | additions: pr.additions ?? 0, | ||
| 141 | deletions: pr.deletions ?? 0, | ||
| 142 | createdAt: toIsoUtc(pr.created_at), | ||
| 143 | updatedAt: toIsoUtc(pr.updated_at), | ||
| 144 | closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at), | ||
| 145 | mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at), | ||
| 146 | reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login), | ||
| 147 | labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })), | ||
| 148 | } satisfies ProviderChangeRequest; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment { | ||
| 152 | return { | ||
| 153 | id: String(comment.id), | ||
| 154 | kind: "issue-comment", | ||
| 155 | author: forgejoActor(comment.user), | ||
| 156 | body: comment.body, | ||
| 157 | createdAt: toIsoUtc(comment.created_at), | ||
| 158 | url: comment.html_url || null, | ||
| 159 | path: null, | ||
| 160 | reviewState: null, | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment { | ||
| 165 | return { | ||
| 166 | id: `review:${review.id}`, | ||
| 167 | kind: "review", | ||
| 168 | author: forgejoActor(review.user), | ||
| 169 | body: review.body, | ||
| 170 | createdAt: toIsoUtc(review.submitted_at), | ||
| 171 | url: review.html_url || null, | ||
| 172 | path: null, | ||
| 173 | reviewState: | ||
| 174 | review.state === "REQUEST_CHANGES" | ||
| 175 | ? "CHANGES_REQUESTED" | ||
| 176 | : review.state === "COMMENT" | ||
| 177 | ? "COMMENTED" | ||
| 178 | : review.state, | ||
| 179 | }; | ||
| 180 | } | ||
| 181 | |||
| 182 | export function forgejoReviewThread( | ||
| 183 | comment: typeof ForgejoReviewComment.Type, | ||
| 184 | ): PullRequestReviewThread { | ||
| 185 | const oldSide = comment.position === 0 && comment.original_position > 0; | ||
| 186 | const line = oldSide ? comment.original_position : comment.position; | ||
| 187 | return { | ||
| 188 | id: String(comment.id), | ||
| 189 | path: comment.path, | ||
| 190 | line: line > 0 ? line : null, | ||
| 191 | side: oldSide ? "left" : "right", | ||
| 192 | isResolved: comment.resolver !== null, | ||
| 193 | isOutdated: false, | ||
| 194 | comments: [forgejoComment(comment)], | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | |||
| 198 | export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit { | ||
| 199 | const author = forgejoActor(commit.author); | ||
| 200 | return { | ||
| 201 | oid: commit.sha, | ||
| 202 | messageHeadline: commit.commit.message.split("\n")[0] ?? "", | ||
| 203 | committedDate: toIsoUtc(commit.commit.committer.date), | ||
| 204 | authors: author ? [author] : [], | ||
| 205 | ...(commit.stats | ||
| 206 | ? { additions: commit.stats.additions, deletions: commit.stats.deletions } | ||
| 207 | : {}), | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | |||
| 211 | export function forgejoChecks( | ||
| 212 | statuses: ReadonlyArray<typeof ForgejoStatus.Type>, | ||
| 213 | ): ReadonlyArray<PullRequestCheck> { | ||
| 214 | return dedupeChecks( | ||
| 215 | statuses.map((status) => ({ | ||
| 216 | workflowName: null, | ||
| 217 | at: status.updated_at, | ||
| 218 | check: { | ||
| 219 | name: status.context || "check", | ||
| 220 | description: status.description || null, | ||
| 221 | url: status.target_url || null, | ||
| 222 | status: | ||
| 223 | status.status === "success" | ||
| 224 | ? "success" | ||
| 225 | : status.status === "failure" || status.status === "error" | ||
| 226 | ? "failure" | ||
| 227 | : "pending", | ||
| 228 | }, | ||
| 229 | })), | ||
| 230 | ); | ||
| 231 | } | ||
| 232 | |||
| 233 | export const FORGEJO_REACTIONS: Record<PullRequestReactionContent, string> = { | ||
| 234 | "thumbs-up": "+1", | ||
| 235 | "thumbs-down": "-1", | ||
| 236 | laugh: "laugh", | ||
| 237 | hooray: "hooray", | ||
| 238 | confused: "confused", | ||
| 239 | heart: "heart", | ||
| 240 | rocket: "rocket", | ||
| 241 | eyes: "eyes", | ||
| 242 | }; | ||
| 243 | export function forgejoReactions( | ||
| 244 | reactions: ReadonlyArray<typeof ForgejoReaction.Type>, | ||
| 245 | viewer: string, | ||
| 246 | ): ReadonlyArray<PullRequestReaction> { | ||
| 247 | return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => { | ||
| 248 | const matching = reactions.filter((reaction) => reaction.content === emoji); | ||
| 249 | return matching.length === 0 | ||
| 250 | ? [] | ||
| 251 | : [ | ||
| 252 | { | ||
| 253 | content: content as PullRequestReactionContent, | ||
| 254 | count: matching.length, | ||
| 255 | actors: matching.flatMap((reaction) => | ||
| 256 | reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [], | ||
| 257 | ), | ||
| 258 | viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer), | ||
| 259 | }, | ||
| 260 | ]; | ||
| 261 | }); | ||
| 262 | } | ||
packages/source-control-forgejo/src/server/forgejoPullRequests.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import type { ChangeRequest } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | const Repository = Schema.Struct({ | ||
| 6 | full_name: Schema.String, | ||
| 7 | owner: Schema.Struct({ login: Schema.String }), | ||
| 8 | }); | ||
| 9 | const Branch = Schema.Struct({ | ||
| 10 | ref: Schema.String, | ||
| 11 | sha: Schema.String, | ||
| 12 | repo: Schema.NullOr(Repository), | ||
| 13 | }); | ||
| 14 | export const ForgejoPullRequestSchema = Schema.Struct({ | ||
| 15 | number: Schema.Int, | ||
| 16 | title: Schema.String, | ||
| 17 | html_url: Schema.String, | ||
| 18 | state: Schema.String, | ||
| 19 | merged: Schema.Boolean, | ||
| 20 | draft: Schema.optional(Schema.Boolean), | ||
| 21 | base: Branch, | ||
| 22 | head: Branch, | ||
| 23 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 24 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 25 | updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 26 | }); | ||
| 27 | export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest { | ||
| 28 | return { | ||
| 29 | provider: "forgejo", | ||
| 30 | number: raw.number, | ||
| 31 | title: raw.title, | ||
| 32 | url: raw.html_url, | ||
| 33 | state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open", | ||
| 34 | isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title), | ||
| 35 | baseRefName: raw.base.ref, | ||
| 36 | headRefName: raw.head.ref, | ||
| 37 | closedAt: raw.closed_at ?? null, | ||
| 38 | mergedAt: raw.merged_at ?? null, | ||
| 39 | updatedAt: raw.updated_at ?? Option.none(), | ||
| 40 | isCrossRepository: | ||
| 41 | raw.head.repo !== null && | ||
| 42 | raw.base.repo !== null && | ||
| 43 | raw.head.repo.full_name !== raw.base.repo.full_name, | ||
| 44 | headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null, | ||
| 45 | headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null, | ||
| 46 | }; | ||
| 47 | } | ||
packages/source-control-forgejo/tsconfig.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 6 | "include": ["src"] | ||
| 7 | } | ||
packages/source-control-testing/package.json created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/source-control-testing", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | "./TestSourceControlHost": { | ||
| 7 | "types": "./src/TestSourceControlHost.ts", | ||
| 8 | "import": "./src/TestSourceControlHost.ts" | ||
| 9 | } | ||
| 10 | }, | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "tsc --noEmit" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@t3tools/contracts": "workspace:*", | ||
| 16 | "@t3tools/source-control-core": "workspace:*", | ||
| 17 | "effect": "catalog:" | ||
| 18 | }, | ||
| 19 | "devDependencies": { | ||
| 20 | "@types/node": "catalog:" | ||
| 21 | } | ||
| 22 | } | ||
packages/source-control-testing/src/TestSourceControlHost.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | /** | ||
| 2 | * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test | ||
| 3 | * supplies its own, and every process run goes to the test's `run`, which fails by default so an | ||
| 4 | * unexpected CLI call is visible. | ||
| 5 | * | ||
| 6 | * @module source-control-testing/TestSourceControlHost | ||
| 7 | */ | ||
| 8 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; | ||
| 9 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 13 | |||
| 14 | export interface TestSourceControlHostOptions { | ||
| 15 | readonly settings?: ServerSettings; | ||
| 16 | readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const layer = ( | ||
| 20 | options: TestSourceControlHostOptions = {}, | ||
| 21 | ): Layer.Layer<SourceControlHost.SourceControlHost> => | ||
| 22 | Layer.succeed( | ||
| 23 | SourceControlHost.SourceControlHost, | ||
| 24 | SourceControlHost.SourceControlHost.of({ | ||
| 25 | settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) }, | ||
| 26 | process: { | ||
| 27 | run: | ||
| 28 | options.process?.run ?? | ||
| 29 | ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), | ||
| 30 | }, | ||
| 31 | }), | ||
| 32 | ); | ||
| 33 | |||
| 34 | /** A successful run's output, for tests that script CLI responses. */ | ||
| 35 | export const processOutput = ( | ||
| 36 | stdout: string, | ||
| 37 | options?: { | ||
| 38 | readonly stderr?: string; | ||
| 39 | readonly exitCode?: ChildProcessSpawner.ExitCode; | ||
| 40 | }, | ||
| 41 | ): SourceControlHost.SourceControlProcessOutput => ({ | ||
| 42 | exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), | ||
| 43 | stdout, | ||
| 44 | stderr: options?.stderr ?? "", | ||
| 45 | stdoutTruncated: false, | ||
| 46 | stderrTruncated: false, | ||
| 47 | }); | ||
packages/source-control-testing/tsconfig.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 6 | "include": ["src"] | ||
| 7 | } | ||
pnpm-lock.yaml+53| ... | @@ -651,6 +651,9 @@ importers: | ... | @@ -651,6 +651,9 @@ importers: |
| 651 | '@t3tools/source-control-core': | 651 | '@t3tools/source-control-core': |
| 652 | specifier: workspace:* | 652 | specifier: workspace:* |
| 653 | version: link:../../packages/source-control-core | 653 | version: link:../../packages/source-control-core |
| 654 | '@t3tools/source-control-forgejo': | ||
| 655 | specifier: workspace:* | ||
| 656 | version: link:../../packages/source-control-forgejo | ||
| 654 | diff: | 657 | diff: |
| 655 | specifier: 8.0.3 | 658 | specifier: 8.0.3 |
| 656 | version: 8.0.3 | 659 | version: 8.0.3 |
| ... | @@ -1518,6 +1521,9 @@ importers: | ... | @@ -1518,6 +1521,9 @@ importers: |
| 1518 | '@t3tools/contracts': | 1521 | '@t3tools/contracts': |
| 1519 | specifier: workspace:* | 1522 | specifier: workspace:* |
| 1520 | version: link:../contracts | 1523 | version: link:../contracts |
| 1524 | '@t3tools/shared': | ||
| 1525 | specifier: workspace:* | ||
| 1526 | version: link:../shared | ||
| 1521 | effect: | 1527 | effect: |
| 1522 | specifier: 4.0.2 | 1528 | specifier: 4.0.2 |
| 1523 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | 1529 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |
| ... | @@ -1532,6 +1538,53 @@ importers: | ... | @@ -1532,6 +1538,53 @@ importers: |
| 1532 | specifier: 1.0.0 | 1538 | specifier: 1.0.0 |
| 1533 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | 1539 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1534 | 1540 | ||
| 1541 | packages/source-control-forgejo: | ||
| 1542 | dependencies: | ||
| 1543 | '@t3tools/contracts': | ||
| 1544 | specifier: workspace:* | ||
| 1545 | version: link:../contracts | ||
| 1546 | '@t3tools/provider-core': | ||
| 1547 | specifier: workspace:* | ||
| 1548 | version: link:../provider-core | ||
| 1549 | '@t3tools/shared': | ||
| 1550 | specifier: workspace:* | ||
| 1551 | version: link:../shared | ||
| 1552 | '@t3tools/source-control-core': | ||
| 1553 | specifier: workspace:* | ||
| 1554 | version: link:../source-control-core | ||
| 1555 | effect: | ||
| 1556 | specifier: 4.0.2 | ||
| 1557 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | ||
| 1558 | devDependencies: | ||
| 1559 | '@effect/vitest': | ||
| 1560 | specifier: 4.0.2 | ||
| 1561 | version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | ||
| 1562 | '@t3tools/source-control-testing': | ||
| 1563 | specifier: workspace:* | ||
| 1564 | version: link:../source-control-testing | ||
| 1565 | '@types/node': | ||
| 1566 | specifier: 24.12.4 | ||
| 1567 | version: 24.12.4 | ||
| 1568 | vite-plus: | ||
| 1569 | specifier: 1.0.0 | ||
| 1570 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | ||
| 1571 | |||
| 1572 | packages/source-control-testing: | ||
| 1573 | dependencies: | ||
| 1574 | '@t3tools/contracts': | ||
| 1575 | specifier: workspace:* | ||
| 1576 | version: link:../contracts | ||
| 1577 | '@t3tools/source-control-core': | ||
| 1578 | specifier: workspace:* | ||
| 1579 | version: link:../source-control-core | ||
| 1580 | effect: | ||
| 1581 | specifier: 4.0.2 | ||
| 1582 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | ||
| 1583 | devDependencies: | ||
| 1584 | '@types/node': | ||
| 1585 | specifier: 24.12.4 | ||
| 1586 | version: 24.12.4 | ||
| 1587 | |||
| 1535 | packages/ssh: | 1588 | packages/ssh: |
| 1536 | dependencies: | 1589 | dependencies: |
| 1537 | '@t3tools/contracts': | 1590 | '@t3tools/contracts': |
scripts/release-smoke.ts+2| ... | @@ -38,6 +38,8 @@ const workspaceFiles = [ | ... | @@ -38,6 +38,8 @@ const workspaceFiles = [ |
| 38 | "packages/provider-muse/package.json", | 38 | "packages/provider-muse/package.json", |
| 39 | "packages/provider-opencode/package.json", | 39 | "packages/provider-opencode/package.json", |
| 40 | "packages/source-control-core/package.json", | 40 | "packages/source-control-core/package.json", |
| 41 | "packages/source-control-forgejo/package.json", | ||
| 42 | "packages/source-control-testing/package.json", | ||
| 41 | "packages/effect-acp/package.json", | 43 | "packages/effect-acp/package.json", |
| 42 | "packages/effect-codex-app-server/package.json", | 44 | "packages/effect-codex-app-server/package.json", |
| 43 | "scripts/package.json", | 45 | "scripts/package.json", |