Julius Marmingefmtc7432398 months agoHistory
467 lines · 12.8 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto";import fs from "node:fs/promises";import os from "node:os";import path from "node:path"; import {  gitRunStackedActionInputSchema,  gitRunStackedActionResultSchema,  gitStatusInputSchema,  type GitRunStackedActionInput,  type GitRunStackedActionResult,  type GitStatusInput,  type GitStatusResult,} from "@t3tools/contracts"; import type {  CommitMessageGenerationResult,  PrContentGenerationResult,  TextGenerationService,} from "./coreServices";import { CodexTextGenerator } from "./codexTextGenerator";import { GitCoreService } from "./git";import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; const GH_CLI_TIMEOUT_MS = 30_000; interface GitManagerDeps {  runProcess?: (    command: string,    args: readonly string[],    options?: ProcessRunOptions,  ) => Promise<ProcessRunResult>;  textGenerator?: TextGenerationService;  gitCore?: GitCoreService;} interface OpenPrInfo {  number: number;  title: string;  url: string;  baseRefName: string;  headRefName: string;} function parseOpenPrList(raw: unknown): OpenPrInfo[] {  if (!Array.isArray(raw)) return [];   const parsed: OpenPrInfo[] = [];  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;    if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {      continue;    }    if (      typeof title !== "string" ||      typeof url !== "string" ||      typeof baseRefName !== "string" ||      typeof headRefName !== "string"    ) {      continue;    }    parsed.push({      number,      title,      url,      baseRefName,      headRefName,    });  }  return parsed;} function trimStdout(value: string): string {  return value.trim();} function limitContext(value: string, maxChars: number): string {  if (value.length <= maxChars) return value;  return `${value.slice(0, maxChars)}\n\n[truncated]`;} function sanitizeCommitMessage(  generated: CommitMessageGenerationResult,): CommitMessageGenerationResult {  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(),  };} function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | 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 asCommandNotFound(command: string, error: unknown): Error | undefined {  if (!(error instanceof Error)) return undefined;  if (!error.message.includes(`Command not found: ${command}`)) return undefined;  if (command === "gh") {    return new Error("GitHub CLI (`gh`) is required but not available on PATH.");  }  if (command === "codex") {    return new Error("Codex CLI (`codex`) is required but not available on PATH.");  }  return new Error(`${command} is required but not available on PATH.`);} function normalizeGitHubAuthError(error: unknown): Error | undefined {  if (!(error instanceof Error)) return undefined;  const lower = error.message.toLowerCase();  if (    lower.includes("authentication failed") ||    lower.includes("not logged in") ||    lower.includes("gh auth login") ||    lower.includes("no oauth token")  ) {    return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry.");  }  return undefined;} function toStatusOpenPr(pr: OpenPrInfo): {  number: number;  title: string;  url: string;  baseBranch: string;  headBranch: string;} {  return {    number: pr.number,    title: pr.title,    url: pr.url,    baseBranch: pr.baseRefName,    headBranch: pr.headRefName,  };} export class GitManager {  private readonly run: NonNullable<GitManagerDeps["runProcess"]>;  private readonly textGenerator: TextGenerationService;  private readonly gitCore: GitCoreService;   constructor(deps: GitManagerDeps = {}) {    this.run = deps.runProcess ?? runProcess;    this.textGenerator = deps.textGenerator ?? new CodexTextGenerator();    this.gitCore = deps.gitCore ?? new GitCoreService();  }   async status(raw: GitStatusInput): Promise<GitStatusResult> {    const input = gitStatusInputSchema.parse(raw);    const details = await this.gitCore.statusDetails(input.cwd);     let openPr: ReturnType<typeof toStatusOpenPr> | null = null;    if (details.branch && details.hasUpstream) {      try {        const existing = await this.findOpenPr(input.cwd, details.branch);        if (existing) {          openPr = toStatusOpenPr(existing);        }      } catch {        // PR lookup is best-effort for status rendering.      }    }     return {      branch: details.branch,      hasWorkingTreeChanges: details.hasWorkingTreeChanges,      workingTree: details.workingTree,      hasUpstream: details.hasUpstream,      aheadCount: details.aheadCount,      behindCount: details.behindCount,      openPr,    };  }   async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> {    const input = gitRunStackedActionInputSchema.parse(raw);    const wantsPush = input.action !== "commit";    const wantsPr = input.action === "commit_push_pr";     const initialStatus = await this.gitCore.statusDetails(input.cwd);    if (wantsPush && !initialStatus.branch) {      throw new Error("Cannot push from detached HEAD.");    }    if (wantsPr && !initialStatus.branch) {      throw new Error("Cannot create a pull request from detached HEAD.");    }     const commit = await this.runCommitStep(input.cwd, initialStatus.branch, input.commitMessage);     const push = wantsPush      ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)      : { status: "skipped_not_requested" as const };     const pr = wantsPr      ? await this.runPrStep(input.cwd, initialStatus.branch)      : { status: "skipped_not_requested" as const };     return gitRunStackedActionResultSchema.parse({      action: input.action,      commit,      push,      pr,    });  }   private async runCommitStep(    cwd: string,    branch: string | null,    commitMessage?: string,  ): Promise<{    status: "created" | "skipped_no_changes";    commitSha?: string | undefined;    subject?: string | undefined;  }> {    const context = await this.gitCore.prepareCommitContext(cwd);    if (!context) {      return { status: "skipped_no_changes" };    }     let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage(      commitMessage ?? "",    );     if (!generated) {      try {        generated = sanitizeCommitMessage(          await this.textGenerator.generateCommitMessage({            cwd,            branch,            stagedSummary: limitContext(context.stagedSummary, 8_000),            stagedPatch: limitContext(context.stagedPatch, 50_000),          }),        );      } catch (error) {        throw asCommandNotFound("codex", error) ?? error;      }    }     const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);    return {      status: "created",      commitSha,      subject: generated.subject,    };  }   private async runPrStep(    cwd: string,    fallbackBranch: string | null,  ): Promise<{    status: "created" | "opened_existing" | "skipped_not_requested";    url?: string | undefined;    number?: number | undefined;    baseBranch?: string | undefined;    headBranch?: string | undefined;    title?: string | undefined;  }> {    const details = await this.gitCore.statusDetails(cwd);    const branch = details.branch ?? fallbackBranch;    if (!branch) {      throw new Error("Cannot create a pull request from detached HEAD.");    }    if (!details.hasUpstream) {      throw new Error("Current branch has not been pushed. Push before creating a PR.");    }     const existing = await this.findOpenPr(cwd, branch);    if (existing) {      return {        status: "opened_existing",        url: existing.url,        number: existing.number,        baseBranch: existing.baseRefName,        headBranch: existing.headRefName,        title: existing.title,      };    }     const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef);    const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch);     let generated: PrContentGenerationResult;    try {      generated = await this.textGenerator.generatePrContent({        cwd,        baseBranch,        headBranch: branch,        commitSummary: limitContext(rangeContext.commitSummary, 20_000),        diffSummary: limitContext(rangeContext.diffSummary, 20_000),        diffPatch: limitContext(rangeContext.diffPatch, 60_000),      });    } catch (error) {      throw asCommandNotFound("codex", error) ?? error;    }     const bodyFile = path.join(os.tmpdir(), `t3code-pr-body-${process.pid}-${randomUUID()}.md`);    await fs.writeFile(bodyFile, generated.body, "utf8");     try {      await this.runGh(cwd, [        "pr",        "create",        "--base",        baseBranch,        "--head",        branch,        "--title",        generated.title,        "--body-file",        bodyFile,      ]);    } finally {      try {        await fs.unlink(bodyFile);      } catch {        // Best-effort cleanup.      }    }     const created = await this.findOpenPr(cwd, branch);     if (!created) {      return {        status: "created",        baseBranch,        headBranch: branch,        title: generated.title,      };    }     return {      status: "created",      url: created.url,      number: created.number,      baseBranch: created.baseRefName,      headBranch: created.headRefName,      title: created.title,    };  }   private async findOpenPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {    const stdout = await this.runGhStdout(cwd, [      "pr",      "list",      "--head",      branch,      "--state",      "open",      "--limit",      "1",      "--json",      "number,title,url,baseRefName,headRefName",    ]);     const raw = trimStdout(stdout);    if (raw.length === 0) return null;     let parsedJson: unknown;    try {      parsedJson = JSON.parse(raw);    } catch {      throw new Error("GitHub CLI returned invalid PR list JSON.");    }     const parsed = parseOpenPrList(parsedJson);    return parsed[0] ?? null;  }   private async resolveBaseBranch(    cwd: string,    branch: string,    upstreamRef: string | null,  ): Promise<string> {    const configured = await this.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;      }    }     try {      const ghDefault = await this.runGhStdout(cwd, [        "repo",        "view",        "--json",        "defaultBranchRef",        "--jq",        ".defaultBranchRef.name",      ]);      const defaultBranch = trimStdout(ghDefault);      if (defaultBranch.length > 0) return defaultBranch;    } catch {      // Fall through to deterministic fallback.    }     return "main";  }   private async runGh(cwd: string, args: readonly string[]): Promise<void> {    await this.runGhCommand(cwd, args);  }   private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> {    const result = await this.runGhCommand(cwd, args);    return result.stdout;  }   private async runGhCommand(cwd: string, args: readonly string[]): Promise<ProcessRunResult> {    try {      return await this.run("gh", args, {        cwd,        timeoutMs: GH_CLI_TIMEOUT_MS,      });    } catch (error) {      throw (        asCommandNotFound("gh", error) ??        normalizeGitHubAuthError(error) ??        (error instanceof Error ? error : new Error("GitHub CLI command failed."))      );    }  }}