import * as Arr from "effect/Array";import * as Cache from "effect/Cache";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Order from "effect/Order";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, SourceControlProviderError, type SourceControlWritingStyleSettings,} from "@t3tools/contracts";import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName,} from "@t3tools/shared/git";import { getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology,} from "@t3tools/shared/sourceControl"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts";import * as TextGeneration from "../textGeneration/TextGeneration.ts";import { conventionalCommitsTextGenerationPolicy, customTextGenerationPolicy, repositoryConventionsTextGenerationPolicy,} from "../textGeneration/TextGenerationPresets.ts";import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";import * as ServerSettings from "../serverSettings.ts";import type { GitManagerServiceError } from "@t3tools/contracts";import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;} export interface GitRunStackedActionOptions { readonly actionId?: string; readonly progressReporter?: GitActionProgressReporter;} export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ readonly refreshMissingPullRequest?: boolean;} interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings;} export class GitManager extends Context.Service< GitManager, { readonly status: ( input: VcsStatusInput, ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; readonly remoteStatus: ( input: VcsStatusInput, options?: GitRemoteStatusOptions, ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; /** Resolve the PR for a saved branch without changing the current checkout. */ readonly branchPullRequest: (input: { readonly cwd: string; readonly branch: string; }) => Effect.Effect< { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; }>()("t3/git/GitManager") {} const COMMIT_TIMEOUT_MS = 10 * 60_000;const MAX_PROGRESS_TEXT_LENGTH = 500;const SHORT_SHA_LENGTH = 7;const TOAST_DESCRIPTION_MAX = 72;const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);const STATUS_RESULT_CACHE_CAPACITY = 2_048;// Matches the automatic settlement sweep cadence so every background sweep// reads fresh branch state: an external merge settles within about a minute// instead of waiting out a longer cache. Unpublished branches never reach the// host (a local probe answers first), and failed lookups still back off// exponentially via prLookupFailureTtl, so throttling pressure still drops// under 429s instead of amplifying it.const PR_LOOKUP_CACHE_TTL = Duration.seconds(60);const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);const PR_LOOKUP_CACHE_CAPACITY = 2_048;const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** * How long a failed PR lookup is cached, given the number of consecutive * failures for that branch. * * A hosting provider rejects a throttled request immediately, so caching every * failure for a flat 20s made a rate-limited poller re-ask *faster* than a * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 * into sustained pressure. Backing off per branch keeps the retry rate below * the healthy rate once a branch has failed more than a couple of times. */export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL);}type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; function isNotGitRepositoryError(error: GitCommandError): boolean { return error.message.toLowerCase().includes("not a git repository");} interface OpenPrInfo { number: number; title: string; url: string; baseRefName: string; headRefName: string;} interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; updatedAt: Option.Option<DateTime.Utc>;} const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput( Order.flip(Option.makeOrder(DateTime.Order)), (pullRequest) => pullRequest.updatedAt,); interface ResolvedPullRequest { number: number; title: string; url: string; baseBranch: string; headBranch: string; state: "open" | "closed" | "merged";} interface PullRequestHeadRemoteInfo { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; headRepositoryOwnerLogin?: string | null | undefined;} interface BranchHeadContext { localBranch: string; headBranch: string; headSelectors: ReadonlyArray<string>; preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean;} function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null;} function resolveHeadRepositoryNameWithOwner( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,): string | null { const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; if (explicitRepository.length > 0) { return explicitRepository; } if (!pullRequest.isCrossRepository) { return null; } const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); if (ownerLogin.length === 0 || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`;} function resolvePullRequestWorktreeLocalBranchName( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,): string { if (!pullRequest.isCrossRepository) { return pullRequest.headBranch; } const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; return `t3code/pr-${pullRequest.number}/${suffix}`;} function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;} function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { return null; } const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;} function normalizeOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null;} function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null;} function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null;} function resolvePullRequestHeadRepositoryNameWithOwner( pr: PullRequestHeadRemoteInfo & { url: string },) { const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); if (explicitRepository) { return explicitRepository; } if (!pr.isCrossRepository) { return null; } const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); if (!ownerLogin || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`;} interface PullRequestHeadIdentity { readonly repositoryNameWithOwner: string | null; readonly ownerLogin: string | null;} function resolveExpectedHeadIdentity( headContext: Pick<BranchHeadContext, "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin">,): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( headContext.headRepositoryNameWithOwner, ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), };} function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( resolvePullRequestHeadRepositoryNameWithOwner(pr), ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), };} export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >,): boolean { if (pr.headRefName !== headContext.headBranch) { return false; } const expectedHead = resolveExpectedHeadIdentity(headContext); const pullRequestHead = resolvePullRequestHeadIdentity(pr); if (expectedHead.repositoryNameWithOwner) { if (pullRequestHead.repositoryNameWithOwner) { if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { return false; } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } if (headContext.isCrossRepository) { if (pr.isCrossRepository === false) { return false; } if ( (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && !pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin ) { return false; } return true; } if (pr.isCrossRepository === true) { if ( (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) ) { return false; } } return true;} function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), ...(summary.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } : {}), ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), };} function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`;} function shortenSha(sha: string | undefined): string | null { if (!sha) return null; return sha.slice(0, SHORT_SHA_LENGTH);} function truncateText( value: string | undefined, maxLength = TOAST_DESCRIPTION_MAX,): string | undefined { if (!value) return undefined; if (value.length <= maxLength) return value; if (maxLength <= 3) return "...".slice(0, maxLength); return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;} function withDescription(title: string, description: string | undefined) { return description ? { title, description } : { title };} function summarizeGitActionResult( result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">, terms: ChangeRequestTerminology,): { title: string; description?: string;} { if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } if (result.push.status === "pushed") { const shortSha = shortenSha(result.commit.commitSha); const branch = result.push.upstreamBranch ?? result.push.branch; const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; const branchPart = branch ? ` to ${branch}` : ""; return withDescription( `Pushed${pushedCommitPart}${branchPart}`, truncateText(result.commit.subject), ); } if (result.commit.status === "created") { const shortSha = shortenSha(result.commit.commitSha); const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; return withDescription(title, truncateText(result.commit.subject)); } return { title: "Done" };} 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 } : {}), };} function sanitizeProgressText(value: string): string | null { const trimmed = value.trim(); if (trimmed.length === 0) {Lines 1–500 of 2719. Load more
import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, SourceControlProviderError, type SourceControlWritingStyleSettings, } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { conventionalCommitsTextGenerationPolicy, customTextGenerationPolicy, repositoryConventionsTextGenerationPolicy, } from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; } export interface GitRunStackedActionOptions { readonly actionId?: string; readonly progressReporter?: GitActionProgressReporter; } export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ readonly refreshMissingPullRequest?: boolean; } interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; } export class GitManager extends Context.Service< GitManager, { readonly status: ( input: VcsStatusInput, ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; readonly remoteStatus: ( input: VcsStatusInput, options?: GitRemoteStatusOptions, ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; /** Resolve the PR for a saved branch without changing the current checkout. */ readonly branchPullRequest: (input: { readonly cwd: string; readonly branch: string; }) => Effect.Effect< { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; } >()("t3/git/GitManager") {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; // Matches the automatic settlement sweep cadence so every background sweep // reads fresh branch state: an external merge settles within about a minute // instead of waiting out a longer cache. Unpublished branches never reach the // host (a local probe answers first), and failed lookups still back off // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** * How long a failed PR lookup is cached, given the number of consecutive * failures for that branch. * * A hosting provider rejects a throttled request immediately, so caching every * failure for a flat 20s made a rate-limited poller re-ask *faster* than a * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 * into sustained pressure. Backing off per branch keeps the retry rate below * the healthy rate once a branch has failed more than a couple of times. */ export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); } type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; function isNotGitRepositoryError(error: GitCommandError): boolean { return error.message.toLowerCase().includes("not a git repository"); } interface OpenPrInfo { number: number; title: string; url: string; baseRefName: string; headRefName: string; } interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; updatedAt: Option.Option<DateTime.Utc>; } const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput( Order.flip(Option.makeOrder(DateTime.Order)), (pullRequest) => pullRequest.updatedAt, ); interface ResolvedPullRequest { number: number; title: string; url: string; baseBranch: string; headBranch: string; state: "open" | "closed" | "merged"; } interface PullRequestHeadRemoteInfo { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; headRepositoryOwnerLogin?: string | null | undefined; } interface BranchHeadContext { localBranch: string; headBranch: string; headSelectors: ReadonlyArray<string>; preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; } function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null; } function resolveHeadRepositoryNameWithOwner( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, ): string | null { const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; if (explicitRepository.length > 0) { return explicitRepository; } if (!pullRequest.isCrossRepository) { return null; } const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); if (ownerLogin.length === 0 || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`; } function resolvePullRequestWorktreeLocalBranchName( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, ): string { if (!pullRequest.isCrossRepository) { return pullRequest.headBranch; } const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; return `t3code/pr-${pullRequest.number}/${suffix}`; } function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { return null; } const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } function normalizeOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null; } function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value); return normalized ? normalized.toLowerCase() : null; } function resolvePullRequestHeadRepositoryNameWithOwner( pr: PullRequestHeadRemoteInfo & { url: string }, ) { const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); if (explicitRepository) { return explicitRepository; } if (!pr.isCrossRepository) { return null; } const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); if (!ownerLogin || !repositoryName) { return null; } return `${ownerLogin}/${repositoryName}`; } interface PullRequestHeadIdentity { readonly repositoryNameWithOwner: string | null; readonly ownerLogin: string | null; } function resolveExpectedHeadIdentity( headContext: Pick<BranchHeadContext, "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin">, ): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( headContext.headRepositoryNameWithOwner, ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), }; } function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( resolvePullRequestHeadRepositoryNameWithOwner(pr), ); return { repositoryNameWithOwner, ownerLogin: normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? parseRepositoryOwnerLogin(repositoryNameWithOwner), }; } export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" >, ): boolean { if (pr.headRefName !== headContext.headBranch) { return false; } const expectedHead = resolveExpectedHeadIdentity(headContext); const pullRequestHead = resolvePullRequestHeadIdentity(pr); if (expectedHead.repositoryNameWithOwner) { if (pullRequestHead.repositoryNameWithOwner) { if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { return false; } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } } if (headContext.isCrossRepository) { if (pr.isCrossRepository === false) { return false; } if ( (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && !pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin ) { return false; } return true; } if (pr.isCrossRepository === true) { if ( (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) ) { return false; } } return true; } function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), ...(summary.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } : {}), ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), }; } function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; } function shortenSha(sha: string | undefined): string | null { if (!sha) return null; return sha.slice(0, SHORT_SHA_LENGTH); } function truncateText( value: string | undefined, maxLength = TOAST_DESCRIPTION_MAX, ): string | undefined { if (!value) return undefined; if (value.length <= maxLength) return value; if (maxLength <= 3) return "...".slice(0, maxLength); return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } function withDescription(title: string, description: string | undefined) { return description ? { title, description } : { title }; } function summarizeGitActionResult( result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">, terms: ChangeRequestTerminology, ): { title: string; description?: string; } { if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } if (result.push.status === "pushed") { const shortSha = shortenSha(result.commit.commitSha); const branch = result.push.upstreamBranch ?? result.push.branch; const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; const branchPart = branch ? ` to ${branch}` : ""; return withDescription( `Pushed${pushedCommitPart}${branchPart}`, truncateText(result.commit.subject), ); } if (result.commit.status === "created") { const shortSha = shortenSha(result.commit.commitSha); const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; return withDescription(title, truncateText(result.commit.subject)); } return { title: "Done" }; } 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 } : {}), }; } function sanitizeProgressText(value: string): string | null { const trimmed = value.trim(); if (trimmed.length === 0) {