chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import { GitMeta } from "@trigger.dev/core/v3";
|
||||
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { type z } from "zod";
|
||||
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { type BranchesOptions } from "~/utils/branches";
|
||||
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
|
||||
import { checkBranchLimit } from "~/services/upsertBranch.server";
|
||||
import { devPresence } from "./DevPresence.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import {
|
||||
type BranchableEnvironmentToken,
|
||||
type BranchableEnvironmentType,
|
||||
toBranchableEnvironmentType,
|
||||
} from "~/utils/branchableEnvironment";
|
||||
|
||||
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
|
||||
export type Branch = Result["branches"][number];
|
||||
|
||||
const BRANCHES_PER_PAGE = 25;
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment that scopes the branches list by branch name, keyed by
|
||||
* environment type. Spread it into the query's `where` (it contributes either a
|
||||
* `branchName` constraint or a top-level `OR`).
|
||||
*
|
||||
* The default DEV branch is the root dev env, stored with `branchName: null`, so
|
||||
* for DEVELOPMENT we always include the null-branchName root (and still match it
|
||||
* when searching — hence the top-level `OR`, since a scalar field filter can't
|
||||
* express "matches search OR is null"). PREVIEW only ever lists real branches, so
|
||||
* its root (null) is excluded. Passing no `search` yields the "all branches of
|
||||
* this type" fragment.
|
||||
*/
|
||||
function branchNameFilter(
|
||||
envType: BranchableEnvironmentType,
|
||||
search?: string
|
||||
): Prisma.RuntimeEnvironmentWhereInput {
|
||||
switch (envType) {
|
||||
case "DEVELOPMENT":
|
||||
return search
|
||||
? { OR: [{ branchName: { contains: search, mode: "insensitive" } }, { branchName: null }] }
|
||||
: {};
|
||||
case "PREVIEW":
|
||||
return search
|
||||
? { branchName: { contains: search, mode: "insensitive" } }
|
||||
: { branchName: { not: null } };
|
||||
default:
|
||||
throw new Error(`branchNameFilter: unsupported environment type "${envType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
type Options = z.infer<typeof BranchesOptions>;
|
||||
|
||||
export type GitMetaLinks = {
|
||||
/** The cleaned repository URL without any username/password */
|
||||
repositoryUrl: string;
|
||||
/** The branch name */
|
||||
branchName: string;
|
||||
/** Link to the specific branch */
|
||||
branchUrl: string;
|
||||
/** Link to the specific commit */
|
||||
commitUrl: string;
|
||||
/** Link to the pull request (if available) */
|
||||
pullRequestUrl?: string;
|
||||
/** The pull request number (if available) */
|
||||
pullRequestNumber?: number;
|
||||
/** The pull request title (if available) */
|
||||
pullRequestTitle?: string;
|
||||
/** Link to compare this branch with main */
|
||||
compareUrl: string;
|
||||
/** Shortened commit SHA (first 7 characters) */
|
||||
shortSha: string;
|
||||
/** Whether the branch has uncommitted changes */
|
||||
isDirty: boolean;
|
||||
/** The commit message */
|
||||
commitMessage: string;
|
||||
/** The commit author */
|
||||
commitAuthor: string;
|
||||
|
||||
/** The git provider, e.g., `github` */
|
||||
provider?: string;
|
||||
|
||||
source?: "trigger_github_app" | "github_actions" | "local";
|
||||
ghUsername?: string;
|
||||
ghUserAvatarUrl?: string;
|
||||
};
|
||||
|
||||
export class BranchesPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
env,
|
||||
showArchived = false,
|
||||
search,
|
||||
page = 1,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
env: BranchableEnvironmentToken;
|
||||
} & Options) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const envType = toBranchableEnvironmentType(env);
|
||||
|
||||
const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: project.id,
|
||||
type: envType,
|
||||
// The branchable parent is the root env (no parent). For dev that's
|
||||
// derivable; for preview we trust the isBranchableEnvironment column.
|
||||
...(envType === "DEVELOPMENT"
|
||||
? { parentEnvironmentId: null, orgMember: { userId } }
|
||||
: { isBranchableEnvironment: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const hasFilters = !!showArchived || (search !== undefined && search !== "");
|
||||
|
||||
if (!branchableEnvironment) {
|
||||
if (envType === "DEVELOPMENT") {
|
||||
throw new Error("No branchable environment in development environment");
|
||||
}
|
||||
return {
|
||||
branchableEnvironment: null,
|
||||
currentPage: page,
|
||||
totalPages: 0,
|
||||
hasBranches: false,
|
||||
branches: [],
|
||||
hasFilters: false,
|
||||
limits: {
|
||||
used: 0,
|
||||
limit: 0,
|
||||
isAtLimit: true,
|
||||
},
|
||||
canPurchaseBranches: false,
|
||||
extraBranches: 0,
|
||||
branchPricing: null,
|
||||
maxBranchQuota: 0,
|
||||
planBranchLimit: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const branchNameWhere = branchNameFilter(envType, search);
|
||||
const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};
|
||||
|
||||
const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
type: envType,
|
||||
...branchNameWhere,
|
||||
...orgMemberWhere,
|
||||
...(showArchived ? {} : { archivedAt: null }),
|
||||
},
|
||||
});
|
||||
|
||||
const limits = await checkBranchLimit({
|
||||
prisma: this.#prismaClient,
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
userId,
|
||||
type: envType,
|
||||
});
|
||||
|
||||
const [currentPlan, plans] = await Promise.all([
|
||||
getCurrentPlan(project.organizationId),
|
||||
getPlans(),
|
||||
]);
|
||||
|
||||
const canPurchaseBranches =
|
||||
currentPlan?.v3Subscription?.plan?.limits.branches.canExceed === true;
|
||||
const extraBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
|
||||
const maxBranchQuota = currentPlan?.v3Subscription?.addOns?.branches?.quota ?? 0;
|
||||
const planBranchLimit = currentPlan?.v3Subscription?.plan?.limits.branches.number ?? 0;
|
||||
const branchPricing = plans?.addOnPricing.branches ?? null;
|
||||
|
||||
const branches = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
branchName: true,
|
||||
parentEnvironmentId: true,
|
||||
type: true,
|
||||
archivedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
git: true,
|
||||
},
|
||||
where: {
|
||||
projectId: project.id,
|
||||
type: envType,
|
||||
...branchNameWhere,
|
||||
...orgMemberWhere,
|
||||
...(showArchived ? {} : { archivedAt: null }),
|
||||
},
|
||||
orderBy: {
|
||||
branchName: "asc",
|
||||
},
|
||||
skip: (page - 1) * BRANCHES_PER_PAGE,
|
||||
take: BRANCHES_PER_PAGE,
|
||||
});
|
||||
|
||||
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
type: envType,
|
||||
...branchNameFilter(envType),
|
||||
...orgMemberWhere,
|
||||
},
|
||||
});
|
||||
|
||||
const branchesFiltered = branches
|
||||
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
|
||||
.map((branch) => ({
|
||||
...branch,
|
||||
git: processGitMetadata(branch.git),
|
||||
branchName: branch.branchName ?? DEFAULT_DEV_BRANCH,
|
||||
}));
|
||||
|
||||
const branchesWithActivity = await hydrateEnvsWithActivity(
|
||||
userId,
|
||||
project.id,
|
||||
branchesFiltered
|
||||
);
|
||||
const branchesSorted = sortEnvironments(branchesWithActivity);
|
||||
|
||||
return {
|
||||
branchableEnvironment,
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
|
||||
hasBranches: totalBranches > 0,
|
||||
branches: branchesSorted,
|
||||
hasFilters,
|
||||
limits,
|
||||
canPurchaseBranches,
|
||||
extraBranches,
|
||||
branchPricing,
|
||||
maxBranchQuota,
|
||||
planBranchLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function hydrateEnvsWithActivity<
|
||||
T extends { type: RuntimeEnvironmentType; id: string },
|
||||
>(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
environments: T[]
|
||||
): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
|
||||
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);
|
||||
|
||||
const devEnvIds = environments
|
||||
.filter((env) => env.type === "DEVELOPMENT" && recentDevBranchIds.has(env.id))
|
||||
.map((env) => env.id);
|
||||
const connectedMap = await devPresence.isConnectedMany(devEnvIds);
|
||||
|
||||
return environments.map((env) => {
|
||||
if (env.type !== "DEVELOPMENT") {
|
||||
return { ...env, lastActivity: undefined, isConnected: undefined };
|
||||
}
|
||||
|
||||
const devHit = recentDevBranchIds.get(env.id);
|
||||
const lastActivity = devHit === undefined ? undefined : devHit;
|
||||
const isConnected = devHit === undefined ? undefined : (connectedMap.get(env.id) ?? false);
|
||||
return { ...env, lastActivity, isConnected };
|
||||
});
|
||||
}
|
||||
|
||||
export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
|
||||
if (!data) return null;
|
||||
|
||||
const parsed = GitMeta.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.data.remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL
|
||||
const cleanRemoteUrl = (() => {
|
||||
try {
|
||||
const url = new URL(parsed.data.remoteUrl);
|
||||
// Remove any username/password from the URL
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
// Ensure we're using https
|
||||
url.protocol = "https:";
|
||||
// Remove any trailing .git
|
||||
return url.toString().replace(/\.git$/, "");
|
||||
} catch (_e) {
|
||||
// If URL parsing fails, try to clean it manually
|
||||
return parsed.data.remoteUrl
|
||||
.replace(/^git@github\.com:/, "https://github.com/")
|
||||
.replace(/^https?:\/\/[^@]+@/, "https://")
|
||||
.replace(/\.git$/, "");
|
||||
}
|
||||
})();
|
||||
|
||||
if (!parsed.data.commitRef || !parsed.data.commitSha) return null;
|
||||
|
||||
const shortSha = parsed.data.commitSha.slice(0, 7);
|
||||
|
||||
return {
|
||||
repositoryUrl: cleanRemoteUrl,
|
||||
branchName: parsed.data.commitRef,
|
||||
branchUrl: `${cleanRemoteUrl}/tree/${parsed.data.commitRef}`,
|
||||
commitUrl: `${cleanRemoteUrl}/commit/${parsed.data.commitSha}`,
|
||||
pullRequestUrl: parsed.data.pullRequestNumber
|
||||
? `${cleanRemoteUrl}/pull/${parsed.data.pullRequestNumber}`
|
||||
: undefined,
|
||||
pullRequestNumber: parsed.data.pullRequestNumber,
|
||||
pullRequestTitle: parsed.data.pullRequestTitle,
|
||||
compareUrl: `${cleanRemoteUrl}/compare/main...${parsed.data.commitRef}`,
|
||||
shortSha,
|
||||
isDirty: parsed.data.dirty ?? false,
|
||||
commitMessage: parsed.data.commitMessage ?? "",
|
||||
commitAuthor: parsed.data.commitAuthorName ?? "",
|
||||
provider: parsed.data.provider,
|
||||
source: parsed.data.source,
|
||||
ghUsername: parsed.data.ghUsername,
|
||||
ghUserAvatarUrl: parsed.data.ghUserAvatarUrl,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user