| author | |
| committer | |
| log | 350d76ee141004e202d6c365d29ffdb29bde66c6 |
| tree | 6fcae55f5988fd7e0bf0e34994bb9b274e33e8ba |
| parent | 6d7fe2eeb7d2731da3f7f69988056ff7a5d3b921 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>6 files changed, 143 insertions(+), 144 deletions(-)
apps/server/src/git/GitManager.test.ts+2-1| ... | ... | @@ -14,9 +14,10 @@ import type { |
| 14 | 14 | ThreadId, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | |
| 17 | import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; | |
| 17 | import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; | |
| 18 | 18 | import { type GitManagerShape } from "./GitManager.ts"; |
| 19 | 19 | import { |
| 20 | GitHubCliError, | |
| 20 | 21 | type GitHubCliShape, |
| 21 | 22 | type GitHubPullRequestSummary, |
| 22 | 23 | GitHubCli, |
apps/server/src/git/githubPullRequests.ts deleted-127| ... | ... | @@ -1,127 +0,0 @@ |
| 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 4 | ||
| 5 | export interface NormalizedGitHubPullRequestRecord { | |
| 6 | readonly number: number; | |
| 7 | readonly title: string; | |
| 8 | readonly url: string; | |
| 9 | readonly baseRefName: string; | |
| 10 | readonly headRefName: string; | |
| 11 | readonly state: "open" | "closed" | "merged"; | |
| 12 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 13 | readonly isCrossRepository?: boolean; | |
| 14 | readonly headRepositoryNameWithOwner?: string | null; | |
| 15 | readonly headRepositoryOwnerLogin?: string | null; | |
| 16 | } | |
| 17 | ||
| 18 | const GitHubPullRequestSchema = Schema.Struct({ | |
| 19 | number: PositiveInt, | |
| 20 | title: TrimmedNonEmptyString, | |
| 21 | url: TrimmedNonEmptyString, | |
| 22 | baseRefName: TrimmedNonEmptyString, | |
| 23 | headRefName: TrimmedNonEmptyString, | |
| 24 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 25 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 26 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 27 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 28 | headRepository: Schema.optional( | |
| 29 | Schema.NullOr( | |
| 30 | Schema.Struct({ | |
| 31 | nameWithOwner: Schema.String, | |
| 32 | }), | |
| 33 | ), | |
| 34 | ), | |
| 35 | headRepositoryOwner: Schema.optional( | |
| 36 | Schema.NullOr( | |
| 37 | Schema.Struct({ | |
| 38 | login: Schema.String, | |
| 39 | }), | |
| 40 | ), | |
| 41 | ), | |
| 42 | }); | |
| 43 | ||
| 44 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 45 | const trimmed = value?.trim() ?? ""; | |
| 46 | return trimmed.length > 0 ? trimmed : null; | |
| 47 | } | |
| 48 | ||
| 49 | function normalizeGitHubPullRequestState(input: { | |
| 50 | state?: string | null | undefined; | |
| 51 | mergedAt?: string | null | undefined; | |
| 52 | }): "open" | "closed" | "merged" { | |
| 53 | const normalizedState = input.state?.trim().toUpperCase(); | |
| 54 | if ( | |
| 55 | (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || | |
| 56 | normalizedState === "MERGED" | |
| 57 | ) { | |
| 58 | return "merged"; | |
| 59 | } | |
| 60 | if (normalizedState === "CLOSED") { | |
| 61 | return "closed"; | |
| 62 | } | |
| 63 | return "open"; | |
| 64 | } | |
| 65 | ||
| 66 | function normalizeGitHubPullRequestRecord( | |
| 67 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | |
| 68 | ): NormalizedGitHubPullRequestRecord { | |
| 69 | const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | |
| 70 | const headRepositoryOwnerLogin = | |
| 71 | trimOptionalString(raw.headRepositoryOwner?.login) ?? | |
| 72 | (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | |
| 73 | ? (headRepositoryNameWithOwner.split("/")[0] ?? null) | |
| 74 | : null); | |
| 75 | ||
| 76 | return { | |
| 77 | number: raw.number, | |
| 78 | title: raw.title, | |
| 79 | url: raw.url, | |
| 80 | baseRefName: raw.baseRefName, | |
| 81 | headRefName: raw.headRefName, | |
| 82 | state: normalizeGitHubPullRequestState(raw), | |
| 83 | updatedAt: raw.updatedAt ?? Option.none(), | |
| 84 | ...(typeof raw.isCrossRepository === "boolean" | |
| 85 | ? { isCrossRepository: raw.isCrossRepository } | |
| 86 | : {}), | |
| 87 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 88 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| 92 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 93 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | |
| 94 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 95 | ||
| 96 | export const formatGitHubJsonDecodeError = formatSchemaError; | |
| 97 | ||
| 98 | export function decodeGitHubPullRequestListJson( | |
| 99 | raw: string, | |
| 100 | ): Result.Result< | |
| 101 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 102 | Cause.Cause<Schema.SchemaError> | |
| 103 | > { | |
| 104 | const result = decodeGitHubPullRequestList(raw); | |
| 105 | if (Result.isSuccess(result)) { | |
| 106 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | |
| 107 | for (const entry of result.success) { | |
| 108 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | |
| 109 | if (Exit.isFailure(decodedEntry)) { | |
| 110 | continue; | |
| 111 | } | |
| 112 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | |
| 113 | } | |
| 114 | return Result.succeed(pullRequests); | |
| 115 | } | |
| 116 | return Result.fail(result.failure); | |
| 117 | } | |
| 118 | ||
| 119 | export function decodeGitHubPullRequestJson( | |
| 120 | raw: string, | |
| 121 | ): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | |
| 122 | const result = decodeGitHubPullRequest(raw); | |
| 123 | if (Result.isSuccess(result)) { | |
| 124 | return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); | |
| 125 | } | |
| 126 | return Result.fail(result.failure); | |
| 127 | } |
apps/server/src/sourceControl/GitHubCli.ts+12-2| ... | ... | @@ -1,16 +1,26 @@ |
| 1 | 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; |
| 2 | 2 | |
| 3 | import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; | |
| 3 | import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; |
| 6 | 6 | import { |
| 7 | 7 | decodeGitHubPullRequestJson, |
| 8 | 8 | decodeGitHubPullRequestListJson, |
| 9 | 9 | formatGitHubJsonDecodeError, |
| 10 | } from "../git/githubPullRequests.ts"; | |
| 10 | } from "./gitHubPullRequests.ts"; | |
| 11 | 11 | |
| 12 | 12 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 13 | 13 | |
| 14 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 15 | operation: Schema.String, | |
| 16 | detail: Schema.String, | |
| 17 | cause: Schema.optional(Schema.Defect), | |
| 18 | }) { | |
| 19 | override get message(): string { | |
| 20 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 14 | 24 | export interface GitHubPullRequestSummary { |
| 15 | 25 | readonly number: number; |
| 16 | 26 | readonly title: string; |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+2-3| ... | ... | @@ -3,11 +3,10 @@ import { |
| 3 | 3 | SourceControlProviderError, |
| 4 | 4 | type ChangeRequest, |
| 5 | 5 | type ChangeRequestState, |
| 6 | type GitHubCliError, | |
| 7 | 6 | } from "@t3tools/contracts"; |
| 8 | 7 | |
| 9 | import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | |
| 10 | import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts"; | |
| 8 | import { GitHubCli, type GitHubCliError, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | |
| 9 | import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; | |
| 11 | 10 | import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; |
| 12 | 11 | |
| 13 | 12 | function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { |
apps/server/src/sourceControl/gitHubPullRequests.ts created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 4 | ||
| 5 | export interface NormalizedGitHubPullRequestRecord { | |
| 6 | readonly number: number; | |
| 7 | readonly title: string; | |
| 8 | readonly url: string; | |
| 9 | readonly baseRefName: string; | |
| 10 | readonly headRefName: string; | |
| 11 | readonly state: "open" | "closed" | "merged"; | |
| 12 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 13 | readonly isCrossRepository?: boolean; | |
| 14 | readonly headRepositoryNameWithOwner?: string | null; | |
| 15 | readonly headRepositoryOwnerLogin?: string | null; | |
| 16 | } | |
| 17 | ||
| 18 | const GitHubPullRequestSchema = Schema.Struct({ | |
| 19 | number: PositiveInt, | |
| 20 | title: TrimmedNonEmptyString, | |
| 21 | url: TrimmedNonEmptyString, | |
| 22 | baseRefName: TrimmedNonEmptyString, | |
| 23 | headRefName: TrimmedNonEmptyString, | |
| 24 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 25 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 26 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 27 | isCrossRepository: Schema.optional(Schema.Boolean), | |
| 28 | headRepository: Schema.optional( | |
| 29 | Schema.NullOr( | |
| 30 | Schema.Struct({ | |
| 31 | nameWithOwner: Schema.String, | |
| 32 | }), | |
| 33 | ), | |
| 34 | ), | |
| 35 | headRepositoryOwner: Schema.optional( | |
| 36 | Schema.NullOr( | |
| 37 | Schema.Struct({ | |
| 38 | login: Schema.String, | |
| 39 | }), | |
| 40 | ), | |
| 41 | ), | |
| 42 | }); | |
| 43 | ||
| 44 | function trimOptionalString(value: string | null | undefined): string | null { | |
| 45 | const trimmed = value?.trim() ?? ""; | |
| 46 | return trimmed.length > 0 ? trimmed : null; | |
| 47 | } | |
| 48 | ||
| 49 | function normalizeGitHubPullRequestState(input: { | |
| 50 | state?: string | null | undefined; | |
| 51 | mergedAt?: string | null | undefined; | |
| 52 | }): "open" | "closed" | "merged" { | |
| 53 | const normalizedState = input.state?.trim().toUpperCase(); | |
| 54 | if ( | |
| 55 | (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || | |
| 56 | normalizedState === "MERGED" | |
| 57 | ) { | |
| 58 | return "merged"; | |
| 59 | } | |
| 60 | if (normalizedState === "CLOSED") { | |
| 61 | return "closed"; | |
| 62 | } | |
| 63 | return "open"; | |
| 64 | } | |
| 65 | ||
| 66 | function normalizeGitHubPullRequestRecord( | |
| 67 | raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | |
| 68 | ): NormalizedGitHubPullRequestRecord { | |
| 69 | const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | |
| 70 | const headRepositoryOwnerLogin = | |
| 71 | trimOptionalString(raw.headRepositoryOwner?.login) ?? | |
| 72 | (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | |
| 73 | ? (headRepositoryNameWithOwner.split("/")[0] ?? null) | |
| 74 | : null); | |
| 75 | ||
| 76 | return { | |
| 77 | number: raw.number, | |
| 78 | title: raw.title, | |
| 79 | url: raw.url, | |
| 80 | baseRefName: raw.baseRefName, | |
| 81 | headRefName: raw.headRefName, | |
| 82 | state: normalizeGitHubPullRequestState(raw), | |
| 83 | updatedAt: raw.updatedAt ?? Option.none(), | |
| 84 | ...(typeof raw.isCrossRepository === "boolean" | |
| 85 | ? { isCrossRepository: raw.isCrossRepository } | |
| 86 | : {}), | |
| 87 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 88 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| 92 | const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 93 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | |
| 94 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 95 | ||
| 96 | export const formatGitHubJsonDecodeError = formatSchemaError; | |
| 97 | ||
| 98 | export function decodeGitHubPullRequestListJson( | |
| 99 | raw: string, | |
| 100 | ): Result.Result< | |
| 101 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 102 | Cause.Cause<Schema.SchemaError> | |
| 103 | > { | |
| 104 | const result = decodeGitHubPullRequestList(raw); | |
| 105 | if (Result.isSuccess(result)) { | |
| 106 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | |
| 107 | for (const entry of result.success) { | |
| 108 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | |
| 109 | if (Exit.isFailure(decodedEntry)) { | |
| 110 | continue; | |
| 111 | } | |
| 112 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | |
| 113 | } | |
| 114 | return Result.succeed(pullRequests); | |
| 115 | } | |
| 116 | return Result.fail(result.failure); | |
| 117 | } | |
| 118 | ||
| 119 | export function decodeGitHubPullRequestJson( | |
| 120 | raw: string, | |
| 121 | ): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | |
| 122 | const result = decodeGitHubPullRequest(raw); | |
| 123 | if (Result.isSuccess(result)) { | |
| 124 | return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); | |
| 125 | } | |
| 126 | return Result.fail(result.failure); | |
| 127 | } |
packages/contracts/src/git.ts-11| ... | ... | @@ -328,16 +328,6 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()( |
| 328 | 328 | } |
| 329 | 329 | } |
| 330 | 330 | |
| 331 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 332 | operation: Schema.String, | |
| 333 | detail: Schema.String, | |
| 334 | cause: Schema.optional(Schema.Defect), | |
| 335 | }) { | |
| 336 | override get message(): string { | |
| 337 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | 331 | export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()( |
| 342 | 332 | "TextGenerationError", |
| 343 | 333 | { |
| ... | ... | @@ -364,7 +354,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()( |
| 364 | 354 | export const GitManagerServiceError = Schema.Union([ |
| 365 | 355 | GitManagerError, |
| 366 | 356 | GitCommandError, |
| 367 | GitHubCliError, | |
| 368 | 357 | SourceControlProviderError, |
| 369 | 358 | TextGenerationError, |
| 370 | 359 | ]); |