543 lines · 16.1 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Path } from "effect";import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { GitManagerError } from "../Errors.ts";import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";import { GitCore } from "../Services/GitCore.ts";import { GitHubCli } from "../Services/GitHubCli.ts";import { TextGeneration } from "../Services/TextGeneration.ts"; interface OpenPrInfo {  number: number;  title: string;  url: string;  baseRefName: string;  headRefName: string;} interface PullRequestInfo extends OpenPrInfo {  state: "open" | "closed" | "merged";  updatedAt: string | null;} function parsePullRequestList(raw: unknown): PullRequestInfo[] {  if (!Array.isArray(raw)) return [];   const parsed: PullRequestInfo[] = [];  for (const entry of raw) {    if (!entry || typeof entry !== "object") continue;    const record = entry as Record<string, unknown>;    const number = record.number;    const title = record.title;    const url = record.url;    const baseRefName = record.baseRefName;    const headRefName = record.headRefName;    const state = record.state;    const mergedAt = record.mergedAt;    const updatedAt = record.updatedAt;    if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {      continue;    }    if (      typeof title !== "string" ||      typeof url !== "string" ||      typeof baseRefName !== "string" ||      typeof headRefName !== "string"    ) {      continue;    }     let normalizedState: "open" | "closed" | "merged";    if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {      normalizedState = "merged";    } else if (state === "OPEN" || state === undefined || state === null) {      normalizedState = "open";    } else if (state === "CLOSED") {      normalizedState = "closed";    } else {      continue;    }     parsed.push({      number,      title,      url,      baseRefName,      headRefName,      state: normalizedState,      updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,    });  }  return parsed;} function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {  return new GitManagerError({    operation,    detail,    ...(cause !== undefined ? { cause } : {}),  });} function limitContext(value: string, maxChars: number): string {  if (value.length <= maxChars) return value;  return `${value.slice(0, maxChars)}\n\n[truncated]`;} function sanitizeCommitMessage(generated: {  subject: string;  body: string;  branch?: string | undefined;}): {  subject: string;  body: string;  branch?: string | undefined;} {  const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";  const subject = rawSubject.replace(/[.]+$/g, "").trim();  const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";  return {    subject: safeSubject,    body: generated.body.trim(),    ...(generated.branch !== undefined ? { branch: generated.branch } : {}),  };} interface CommitAndBranchSuggestion {  subject: string;  body: string;  branch?: string | undefined;  commitMessage: string;} function formatCommitMessage(subject: string, body: string): string {  const trimmedBody = body.trim();  if (trimmedBody.length === 0) {    return subject;  }  return `${subject}\n\n${trimmedBody}`;} function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {  const normalized = raw.replace(/\r\n/g, "\n").trim();  if (normalized.length === 0) {    return null;  }   const [firstLine, ...rest] = normalized.split("\n");  const subject = firstLine?.trim() ?? "";  if (subject.length === 0) {    return null;  }   return {    subject,    body: rest.join("\n").trim(),  };} function extractBranchFromRef(ref: string): string {  const normalized = ref.trim();   if (normalized.startsWith("refs/remotes/")) {    const withoutPrefix = normalized.slice("refs/remotes/".length);    const firstSlash = withoutPrefix.indexOf("/");    if (firstSlash === -1) {      return withoutPrefix.trim();    }    return withoutPrefix.slice(firstSlash + 1).trim();  }   const firstSlash = normalized.indexOf("/");  if (firstSlash === -1) {    return normalized;  }  return normalized.slice(firstSlash + 1).trim();} function toStatusPr(pr: PullRequestInfo): {  number: number;  title: string;  url: string;  baseBranch: string;  headBranch: string;  state: "open" | "closed" | "merged";} {  return {    number: pr.number,    title: pr.title,    url: pr.url,    baseBranch: pr.baseRefName,    headBranch: pr.headRefName,    state: pr.state,  };} export const makeGitManager = Effect.gen(function* () {  const gitCore = yield* GitCore;  const gitHubCli = yield* GitHubCli;  const textGeneration = yield* TextGeneration;  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";   const findOpenPr = (cwd: string, branch: string) =>    gitHubCli      .listOpenPullRequests({        cwd,        headBranch: branch,        limit: 1,      })      .pipe(        Effect.map((prs) => {          const [first] = prs;          if (!first) {            return null;          }          return {            number: first.number,            title: first.title,            url: first.url,            baseRefName: first.baseRefName,            headRefName: first.headRefName,            state: "open",            updatedAt: null,          } satisfies PullRequestInfo;        }),      );   const findLatestPr = (cwd: string, branch: string) =>    Effect.gen(function* () {      const stdout = yield* gitHubCli        .execute({          cwd,          args: [            "pr",            "list",            "--head",            branch,            "--state",            "all",            "--limit",            "20",            "--json",            "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",          ],        })        .pipe(Effect.map((result) => result.stdout));       const raw = stdout.trim();      if (raw.length === 0) {        return null;      }       const parsedJson = yield* Effect.try({        try: () => JSON.parse(raw) as unknown,        catch: (cause) =>          gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),      });       const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {        const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;        const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;        return right - left;      });       const latestOpenPr = parsed.find((pr) => pr.state === "open");      if (latestOpenPr) {        return latestOpenPr;      }      return parsed[0] ?? null;    });   const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>    Effect.gen(function* () {      const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);      if (configured) return configured;       if (upstreamRef) {        const upstreamBranch = extractBranchFromRef(upstreamRef);        if (upstreamBranch.length > 0 && upstreamBranch !== branch) {          return upstreamBranch;        }      }       const defaultFromGh = yield* gitHubCli        .getDefaultBranch({ cwd })        .pipe(Effect.catch(() => Effect.succeed(null)));      if (defaultFromGh) {        return defaultFromGh;      }       return "main";    });   const resolveCommitAndBranchSuggestion = (input: {    cwd: string;    branch: string | null;    commitMessage?: string;    /** When true, also produce a semantic feature branch name. */    includeBranch?: boolean;  }) =>    Effect.gen(function* () {      const context = yield* gitCore.prepareCommitContext(input.cwd);      if (!context) {        return null;      }       const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");      if (customCommit) {        return {          subject: customCommit.subject,          body: customCommit.body,          ...(input.includeBranch            ? { branch: sanitizeFeatureBranchName(customCommit.subject) }            : {}),          commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),        };      }       const generated = yield* textGeneration        .generateCommitMessage({          cwd: input.cwd,          branch: input.branch,          stagedSummary: limitContext(context.stagedSummary, 8_000),          stagedPatch: limitContext(context.stagedPatch, 50_000),          ...(input.includeBranch ? { includeBranch: true } : {}),        })        .pipe(Effect.map((result) => sanitizeCommitMessage(result)));       return {        subject: generated.subject,        body: generated.body,        ...(generated.branch !== undefined ? { branch: generated.branch } : {}),        commitMessage: formatCommitMessage(generated.subject, generated.body),      };    });   const runCommitStep = (    cwd: string,    branch: string | null,    commitMessage?: string,    preResolvedSuggestion?: CommitAndBranchSuggestion,  ) =>    Effect.gen(function* () {      const suggestion =        preResolvedSuggestion ??        (yield* resolveCommitAndBranchSuggestion({          cwd,          branch,          ...(commitMessage ? { commitMessage } : {}),        }));      if (!suggestion) {        return { status: "skipped_no_changes" as const };      }       const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body);      return {        status: "created" as const,        commitSha,        subject: suggestion.subject,      };    });   const runPrStep = (cwd: string, fallbackBranch: string | null) =>    Effect.gen(function* () {      const details = yield* gitCore.statusDetails(cwd);      const branch = details.branch ?? fallbackBranch;      if (!branch) {        return yield* gitManagerError(          "runPrStep",          "Cannot create a pull request from detached HEAD.",        );      }      if (!details.hasUpstream) {        return yield* gitManagerError(          "runPrStep",          "Current branch has not been pushed. Push before creating a PR.",        );      }       const existing = yield* findOpenPr(cwd, branch);      if (existing) {        return {          status: "opened_existing" as const,          url: existing.url,          number: existing.number,          baseBranch: existing.baseRefName,          headBranch: existing.headRefName,          title: existing.title,        };      }       const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef);      const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);       const generated = yield* textGeneration.generatePrContent({        cwd,        baseBranch,        headBranch: branch,        commitSummary: limitContext(rangeContext.commitSummary, 20_000),        diffSummary: limitContext(rangeContext.diffSummary, 20_000),        diffPatch: limitContext(rangeContext.diffPatch, 60_000),      });       const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);      yield* fileSystem        .writeFileString(bodyFile, generated.body)        .pipe(          Effect.mapError((cause) =>            gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),          ),        );      yield* gitHubCli        .createPullRequest({          cwd,          baseBranch,          headBranch: branch,          title: generated.title,          bodyFile,        })        .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));       const created = yield* findOpenPr(cwd, branch);      if (!created) {        return {          status: "created" as const,          baseBranch,          headBranch: branch,          title: generated.title,        };      }       return {        status: "created" as const,        url: created.url,        number: created.number,        baseBranch: created.baseRefName,        headBranch: created.headRefName,        title: created.title,      };    });   const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {    const details = yield* gitCore.statusDetails(input.cwd);     const pr =      details.branch !== null        ? yield* findLatestPr(input.cwd, details.branch).pipe(            Effect.map((latest) => (latest ? toStatusPr(latest) : null)),            Effect.catch(() => Effect.succeed(null)),          )        : null;     return {      branch: details.branch,      hasWorkingTreeChanges: details.hasWorkingTreeChanges,      workingTree: details.workingTree,      hasUpstream: details.hasUpstream,      aheadCount: details.aheadCount,      behindCount: details.behindCount,      pr,    };  });   const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) =>    Effect.gen(function* () {      const suggestion = yield* resolveCommitAndBranchSuggestion({        cwd,        branch,        ...(commitMessage ? { commitMessage } : {}),        includeBranch: true,      });      if (!suggestion) {        return yield* gitManagerError(          "runFeatureBranchStep",          "Cannot create a feature branch because there are no changes to commit.",        );      }       const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);      const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);      const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);       yield* gitCore.createBranch({ cwd, branch: resolvedBranch });      yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));       return {        branchStep: { status: "created" as const, name: resolvedBranch },        resolvedCommitMessage: suggestion.commitMessage,        resolvedCommitSuggestion: suggestion,      };    });   const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced(    function* (input) {      const wantsPush = input.action !== "commit";      const wantsPr = input.action === "commit_push_pr";       const initialStatus = yield* gitCore.statusDetails(input.cwd);      if (!input.featureBranch && wantsPush && !initialStatus.branch) {        return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");      }      if (!input.featureBranch && wantsPr && !initialStatus.branch) {        return yield* gitManagerError(          "runStackedAction",          "Cannot create a pull request from detached HEAD.",        );      }       let branchStep: { status: "created" | "skipped_not_requested"; name?: string };      let commitMessageForStep = input.commitMessage;      let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;       if (input.featureBranch) {        const result = yield* runFeatureBranchStep(          input.cwd,          initialStatus.branch,

Lines 1–500 of 543.