import { Data, Effect, Predicate, Result, Schema } from "effect"; import { PublishError, type FileDiagnostic } from "../pipeline/discover";import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor";import type { AppsRuntime } from "../plugin/runtime";import type { FileSet, SnapshotId } from "../seams/artifact-store";import { parseGitHubSourceUrl, type ParsedGitHubSourceUrl } from "./github-url"; export interface GitHubSourceInput { readonly url: string; readonly ref?: string; readonly token?: string | null; readonly baseUrl?: string; readonly fetch?: typeof globalThis.fetch;} export interface GitHubSourceSnapshot { readonly files: FileSet; readonly url: string; readonly repo: string; readonly ref: string; readonly upstreamSha: string; readonly description?: string; readonly skipped: readonly GitHubSkippedArtifact[];} export class GitHubSourceError extends Data.TaggedError("GitHubSourceError")<{ readonly message: string; readonly status?: number; readonly path?: string; readonly cause?: unknown;}> {} export interface SyncErrorData { readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; readonly message: string; readonly diagnostics?: readonly FileDiagnostic[];} export type GitHubSkippedArtifact = SourceSkippedArtifact; export type GitHubSyncResult = | { readonly status: "published"; readonly snapshotId: SnapshotId; readonly upstreamSha: string; readonly tools: readonly string[]; readonly skipped: readonly GitHubSkippedArtifact[]; readonly errors?: undefined; } | { readonly status: "up-to-date"; readonly upstreamSha: string; readonly tools: readonly string[]; readonly skipped: readonly GitHubSkippedArtifact[]; readonly errors?: undefined; } | { readonly status: "failed"; readonly upstreamSha?: string; readonly tools: readonly string[]; readonly skipped: readonly GitHubSkippedArtifact[]; readonly errors: readonly SyncErrorData[]; }; export interface SyncGitHubSourceInput extends GitHubSourceInput { readonly runtime: AppsRuntime; readonly tenant?: string; readonly scope: string;} const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;const DEFERRED_RE = /^(workflows|ui|skills)\//;const REGULAR_FILE_MODES = new Set(["100644", "100755"]); const trimBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, ""); const encodedRepoPath = (source: ParsedGitHubSourceUrl): string => `${encodeURIComponent(source.owner)}/${encodeURIComponent(source.name)}`; const parseSourceInput = ( input: GitHubSourceInput,): Effect.Effect<ParsedGitHubSourceUrl, GitHubSourceError> => { const parsed = parseGitHubSourceUrl(input.url, { ref: input.ref }); return parsed.ok ? Effect.succeed(parsed.value) : Effect.fail( new GitHubSourceError({ message: parsed.message, path: input.url, }), );}; const acceptedPath = (path: string): boolean => path === "executor.json" || TOOL_RE.test(path); const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value); const asString = (value: unknown): string | undefined => typeof value === "string" && value.length > 0 ? value : undefined; const requestJson = <A>( input: GitHubSourceInput, path: string,): Effect.Effect<A, GitHubSourceError> => Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: async () => { const fetchImpl = input.fetch ?? globalThis.fetch; const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "executor-apps-github-source", }; if (input.token) headers.authorization = `Bearer ${input.token}`; return fetchImpl(`${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`, { headers, }); }, catch: (cause) => new GitHubSourceError({ message: `GitHub request failed: GET ${path}`, path, cause, }), }); if (!response.ok) { return yield* new GitHubSourceError({ message: `GitHub request failed: GET ${path} -> ${response.status}`, status: response.status, path, }); } return yield* Effect.tryPromise({ try: () => response.json() as Promise<A>, catch: (cause) => new GitHubSourceError({ message: `GitHub response was not valid JSON: GET ${path}`, path, cause, }), }); }); interface RepoResponse { readonly default_branch?: unknown;} interface CommitResponse { readonly sha?: unknown; readonly commit?: { readonly tree?: { readonly sha?: unknown; }; };} interface GitCommitResponse { readonly sha?: unknown; readonly tree?: { readonly sha?: unknown; };} interface RefResponse { readonly object?: { readonly sha?: unknown; };} interface TreeEntry { readonly path?: unknown; readonly type?: unknown; readonly mode?: unknown; readonly sha?: unknown; readonly size?: unknown;} interface TreeResponse { readonly tree?: readonly TreeEntry[]; readonly truncated?: unknown;} interface BlobResponse { readonly content?: unknown; readonly encoding?: unknown;} const commitTreeSha = (commit: CommitResponse | GitCommitResponse): string | undefined => { if ("commit" in commit) return asString(commit.commit?.tree?.sha); if ("tree" in commit) return asString(commit.tree?.sha); return undefined;}; const limitError = (diagnostics: readonly FileDiagnostic[]): PublishError => new PublishError({ message: `publish payload exceeds limits (${diagnostics.length} problem(s))`, stage: "discover", diagnostics, }); const checkTreeLimits = (entries: readonly TreeEntry[]): PublishError | null => { const diagnostics: FileDiagnostic[] = []; if (entries.length > PUBLISH_LIMITS.maxFiles) { diagnostics.push({ path: "", message: `publish has ${entries.length} files, exceeding the limit of ${PUBLISH_LIMITS.maxFiles}`, }); } let total = 0; for (const entry of entries) { const path = String(entry.path ?? ""); const size = typeof entry.size === "number" ? entry.size : 0; total += size; if (size > PUBLISH_LIMITS.maxFileBytes) { diagnostics.push({ path, message: `file is ${size} bytes, exceeding the per-file limit of ${PUBLISH_LIMITS.maxFileBytes} bytes`, }); } } if (total > PUBLISH_LIMITS.maxTotalBytes) { diagnostics.push({ path: "", message: `publish total is ${total} bytes, exceeding the total limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, }); } return diagnostics.length === 0 ? null : limitError(diagnostics);}; const classifyTreeEntry = ( entry: TreeEntry,): | { readonly kind: "fetch"; readonly entry: TreeEntry & { readonly path: string; readonly sha: string }; } | { readonly kind: "skip"; readonly skipped: GitHubSkippedArtifact } | null => { const path = asString(entry.path); if (!path) return null; const type = asString(entry.type); const mode = asString(entry.mode); if (type === "tree") return null; if (type !== "blob" || !mode || !REGULAR_FILE_MODES.has(mode)) { return { kind: "skip", skipped: { path, reason: "unsupported file type" } }; } const sha = asString(entry.sha); if (!sha) return { kind: "skip", skipped: { path, reason: "unsupported file type" } }; if (acceptedPath(path)) return { kind: "fetch", entry: { ...entry, path, sha } }; if (DEFERRED_RE.test(path)) { return { kind: "skip", skipped: { path, reason: "not supported yet" } }; } return { kind: "skip", skipped: { path, reason: "ignored" } };}; const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> => Effect.gen(function* () { const encoding = asString(blob.encoding); const content = asString(blob.content); if (!content || encoding !== "base64") { return yield* new GitHubSourceError({ message: `GitHub blob ${path} did not return base64 content`, path, }); } return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8"); }); const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); const executorDescription = ( files: FileSet,): Effect.Effect<string | undefined, GitHubSourceError> => Effect.gen(function* () { const raw = files.get("executor.json"); if (!raw) return undefined; const parsed = yield* decodeExecutorJson(raw).pipe( Effect.mapError( (cause) => new GitHubSourceError({ message: "executor.json is not valid JSON", path: "executor.json", cause, }), ), ); if (!isRecord(parsed)) return undefined; const description = parsed.description; return typeof description === "string" ? description : undefined; }); export const fetchGitHubSource = ( input: GitHubSourceInput,): Effect.Effect<GitHubSourceSnapshot, GitHubSourceError | PublishError> => Effect.gen(function* () { const source = yield* parseSourceInput(input); const repoPath = encodedRepoPath(source); const repo = yield* requestJson<RepoResponse>(input, `/repos/${repoPath}`); const ref = source.ref ?? asString(repo.default_branch) ?? "main"; const branchRef = yield* requestJson<RefResponse>( input, `/repos/${repoPath}/git/ref/${encodeURIComponent(`heads/${ref}`)}`, ).pipe(Effect.result); const commitPath = Result.isSuccess(branchRef) && asString(branchRef.success.object?.sha) ? `/repos/${repoPath}/git/commits/${encodeURIComponent(String(branchRef.success.object?.sha))}` : `/repos/${repoPath}/commits/${encodeURIComponent(ref)}`; const commit = yield* requestJson<CommitResponse | GitCommitResponse>(input, commitPath); const upstreamSha = asString(commit.sha); const treeSha = commitTreeSha(commit); if (!upstreamSha || !treeSha) { return yield* new GitHubSourceError({ message: `GitHub commit ${ref} did not include a commit SHA and tree SHA`, path: commitPath, }); } const tree = yield* requestJson<TreeResponse>( input, `/repos/${repoPath}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, ); if (tree.truncated === true) { return yield* new GitHubSourceError({ message: "GitHub returned a truncated repository tree; custom tools source is too large", path: `/repos/${repoPath}/git/trees/${treeSha}`, }); } const entries: (TreeEntry & { readonly path: string; readonly sha: string })[] = []; const skipped: GitHubSkippedArtifact[] = []; for (const entry of tree.tree ?? []) { const classified = classifyTreeEntry(entry); if (!classified) continue; if (classified.kind === "fetch") entries.push(classified.entry); else skipped.push(classified.skipped); } const treeLimitError = checkTreeLimits(entries); if (treeLimitError) return yield* treeLimitError; const files = new Map<string, string>(); for (const entry of entries) { const path = String(entry.path); const blob = yield* requestJson<BlobResponse>( input, `/repos/${repoPath}/git/blobs/${encodeURIComponent(String(entry.sha))}`, ); files.set(path, yield* decodeBlob(path, blob)); } const payloadLimitError = enforcePublishLimits(files); if (payloadLimitError) return yield* payloadLimitError; return { files, url: source.ref ? source.url : `https://github.com/${source.repo}`, repo: source.repo, ref, upstreamSha, description: yield* executorDescription(files), skipped, }; }); const publishErrorToSyncError = (publishFailure: PublishError): SyncErrorData => { const stage = publishFailure.stage; const message = publishFailure.message; const diagnostics = publishFailure.diagnostics; return { stage, message, diagnostics };}; const sourceErrorToSyncError = (sourceFailure: GitHubSourceError): SyncErrorData => { const message = sourceFailure.message; const path = sourceFailure.path; return { stage: "source", message, diagnostics: path ? [{ path, message }] : [], };}; const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({ kind: "github", url: snapshot.url, repo: snapshot.repo, ref: snapshot.ref, upstreamSha: snapshot.upstreamSha, skipped: snapshot.skipped,}); export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> => Effect.gen(function* () { const fetched = yield* fetchGitHubSource(input).pipe(Effect.result); if (Result.isFailure(fetched)) { const error = fetched.failure; return { status: "failed", tools: [], skipped: [], errors: [ Predicate.isTagged("PublishError")(error) ? publishErrorToSyncError(error) : sourceErrorToSyncError(error), ], } satisfies GitHubSyncResult; } const snapshot = fetched.success; const current = input.tenant ? yield* input.runtime.getDescriptor(input.tenant, input.scope) : yield* input.runtime.getDescriptor(input.scope); if ( current?.source?.kind === "github" && current.source.repo === snapshot.repo && current.source.upstreamSha === snapshot.upstreamSha ) { return { status: "up-to-date", upstreamSha: snapshot.upstreamSha, tools: current.tools.map((tool) => tool.name), skipped: [...snapshot.skipped, ...(current.skipped ?? [])], } satisfies GitHubSyncResult; } const published = yield* input.runtime .publish({ tenant: input.tenant, scope: input.scope, files: snapshot.files, description: snapshot.description, source: sourceRef(snapshot), message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`, }) .pipe(Effect.result); if (Result.isFailure(published)) { return { status: "failed", upstreamSha: snapshot.upstreamSha, tools: [], skipped: snapshot.skipped, errors: [publishErrorToSyncError(published.failure)], } satisfies GitHubSyncResult; } return { status: "published", snapshotId: published.success.snapshotId, upstreamSha: snapshot.upstreamSha, tools: published.success.descriptor.tools.map((tool) => tool.name), skipped: [...snapshot.skipped, ...published.success.skipped], } satisfies GitHubSyncResult; }); export { parseGitHubSourceUrl } from "./github-url";export type { ParsedGitHubSourceUrl } from "./github-url";
import { Data, Effect, Predicate, Result, Schema } from "effect";
import { PublishError, type FileDiagnostic } from "../pipeline/discover";
import { PUBLISH_LIMITS, enforcePublishLimits } from "../pipeline/publish";
import type { AppSourceRef, SourceSkippedArtifact } from "../pipeline/descriptor";
import type { AppsRuntime } from "../plugin/runtime";
import type { FileSet, SnapshotId } from "../seams/artifact-store";
import { parseGitHubSourceUrl, type ParsedGitHubSourceUrl } from "./github-url";
export interface GitHubSourceInput {
readonly url: string;
readonly ref?: string;
readonly token?: string | null;
readonly baseUrl?: string;
readonly fetch?: typeof globalThis.fetch;
}
export interface GitHubSourceSnapshot {
readonly files: FileSet;
readonly url: string;
readonly repo: string;
readonly ref: string;
readonly upstreamSha: string;
readonly description?: string;
readonly skipped: readonly GitHubSkippedArtifact[];
}
export class GitHubSourceError extends Data.TaggedError("GitHubSourceError")<{
readonly message: string;
readonly status?: number;
readonly path?: string;
readonly cause?: unknown;
}> {}
export interface SyncErrorData {
readonly stage: "source" | "discover" | "bundle" | "collect" | "project";
readonly message: string;
readonly diagnostics?: readonly FileDiagnostic[];
}
export type GitHubSkippedArtifact = SourceSkippedArtifact;
export type GitHubSyncResult =
| {
readonly status: "published";
readonly snapshotId: SnapshotId;
readonly upstreamSha: string;
readonly tools: readonly string[];
readonly skipped: readonly GitHubSkippedArtifact[];
readonly errors?: undefined;
}
| {
readonly status: "up-to-date";
readonly upstreamSha: string;
readonly tools: readonly string[];
readonly skipped: readonly GitHubSkippedArtifact[];
readonly errors?: undefined;
}
| {
readonly status: "failed";
readonly upstreamSha?: string;
readonly tools: readonly string[];
readonly skipped: readonly GitHubSkippedArtifact[];
readonly errors: readonly SyncErrorData[];
};
export interface SyncGitHubSourceInput extends GitHubSourceInput {
readonly runtime: AppsRuntime;
readonly tenant?: string;
readonly scope: string;
}
const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;
const DEFERRED_RE = /^(workflows|ui|skills)\//;
const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
const trimBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, "");
const encodedRepoPath = (source: ParsedGitHubSourceUrl): string =>
`${encodeURIComponent(source.owner)}/${encodeURIComponent(source.name)}`;
const parseSourceInput = (
input: GitHubSourceInput,
): Effect.Effect<ParsedGitHubSourceUrl, GitHubSourceError> => {
const parsed = parseGitHubSourceUrl(input.url, { ref: input.ref });
return parsed.ok
? Effect.succeed(parsed.value)
: Effect.fail(
new GitHubSourceError({
message: parsed.message,
path: input.url,
}),
);
};
const acceptedPath = (path: string): boolean => path === "executor.json" || TOOL_RE.test(path);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
const asString = (value: unknown): string | undefined =>
typeof value === "string" && value.length > 0 ? value : undefined;
const requestJson = <A>(
input: GitHubSourceInput,
path: string,
): Effect.Effect<A, GitHubSourceError> =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise({
try: async () => {
const fetchImpl = input.fetch ?? globalThis.fetch;
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"user-agent": "executor-apps-github-source",
};
if (input.token) headers.authorization = `Bearer ${input.token}`;
return fetchImpl(`${trimBaseUrl(input.baseUrl ?? "https://api.github.com")}${path}`, {
headers,
});
},
catch: (cause) =>
new GitHubSourceError({
message: `GitHub request failed: GET ${path}`,
path,
cause,
}),
});
if (!response.ok) {
return yield* new GitHubSourceError({
message: `GitHub request failed: GET ${path} -> ${response.status}`,
status: response.status,
path,
});
}
return yield* Effect.tryPromise({
try: () => response.json() as Promise<A>,
catch: (cause) =>
new GitHubSourceError({
message: `GitHub response was not valid JSON: GET ${path}`,
path,
cause,
}),
});
});
interface RepoResponse {
readonly default_branch?: unknown;
}
interface CommitResponse {
readonly sha?: unknown;
readonly commit?: {
readonly tree?: {
readonly sha?: unknown;
};
};
}
interface GitCommitResponse {
readonly sha?: unknown;
readonly tree?: {
readonly sha?: unknown;
};
}
interface RefResponse {
readonly object?: {
readonly sha?: unknown;
};
}
interface TreeEntry {
readonly path?: unknown;
readonly type?: unknown;
readonly mode?: unknown;
readonly sha?: unknown;
readonly size?: unknown;
}
interface TreeResponse {
readonly tree?: readonly TreeEntry[];
readonly truncated?: unknown;
}
interface BlobResponse {
readonly content?: unknown;
readonly encoding?: unknown;
}
const commitTreeSha = (commit: CommitResponse | GitCommitResponse): string | undefined => {
if ("commit" in commit) return asString(commit.commit?.tree?.sha);
if ("tree" in commit) return asString(commit.tree?.sha);
return undefined;
};
const limitError = (diagnostics: readonly FileDiagnostic[]): PublishError =>
new PublishError({
message: `publish payload exceeds limits (${diagnostics.length} problem(s))`,
stage: "discover",
diagnostics,
});
const checkTreeLimits = (entries: readonly TreeEntry[]): PublishError | null => {
const diagnostics: FileDiagnostic[] = [];
if (entries.length > PUBLISH_LIMITS.maxFiles) {
diagnostics.push({
path: "",
message: `publish has ${entries.length} files, exceeding the limit of ${PUBLISH_LIMITS.maxFiles}`,
});
}
let total = 0;
for (const entry of entries) {
const path = String(entry.path ?? "");
const size = typeof entry.size === "number" ? entry.size : 0;
total += size;
if (size > PUBLISH_LIMITS.maxFileBytes) {
diagnostics.push({
path,
message: `file is ${size} bytes, exceeding the per-file limit of ${PUBLISH_LIMITS.maxFileBytes} bytes`,
});
}
}
if (total > PUBLISH_LIMITS.maxTotalBytes) {
diagnostics.push({
path: "",
message: `publish total is ${total} bytes, exceeding the total limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`,
});
}
return diagnostics.length === 0 ? null : limitError(diagnostics);
};
const classifyTreeEntry = (
entry: TreeEntry,
):
| {
readonly kind: "fetch";
readonly entry: TreeEntry & { readonly path: string; readonly sha: string };
}
| { readonly kind: "skip"; readonly skipped: GitHubSkippedArtifact }
| null => {
const path = asString(entry.path);
if (!path) return null;
const type = asString(entry.type);
const mode = asString(entry.mode);
if (type === "tree") return null;
if (type !== "blob" || !mode || !REGULAR_FILE_MODES.has(mode)) {
return { kind: "skip", skipped: { path, reason: "unsupported file type" } };
}
const sha = asString(entry.sha);
if (!sha) return { kind: "skip", skipped: { path, reason: "unsupported file type" } };
if (acceptedPath(path)) return { kind: "fetch", entry: { ...entry, path, sha } };
if (DEFERRED_RE.test(path)) {
return { kind: "skip", skipped: { path, reason: "not supported yet" } };
}
return { kind: "skip", skipped: { path, reason: "ignored" } };
};
const decodeBlob = (path: string, blob: BlobResponse): Effect.Effect<string, GitHubSourceError> =>
Effect.gen(function* () {
const encoding = asString(blob.encoding);
const content = asString(blob.content);
if (!content || encoding !== "base64") {
return yield* new GitHubSourceError({
message: `GitHub blob ${path} did not return base64 content`,
path,
});
}
return Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8");
});
const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
const executorDescription = (
files: FileSet,
): Effect.Effect<string | undefined, GitHubSourceError> =>
Effect.gen(function* () {
const raw = files.get("executor.json");
if (!raw) return undefined;
const parsed = yield* decodeExecutorJson(raw).pipe(
Effect.mapError(
(cause) =>
new GitHubSourceError({
message: "executor.json is not valid JSON",
path: "executor.json",
cause,
}),
),
);
if (!isRecord(parsed)) return undefined;
const description = parsed.description;
return typeof description === "string" ? description : undefined;
});
export const fetchGitHubSource = (
input: GitHubSourceInput,
): Effect.Effect<GitHubSourceSnapshot, GitHubSourceError | PublishError> =>
Effect.gen(function* () {
const source = yield* parseSourceInput(input);
const repoPath = encodedRepoPath(source);
const repo = yield* requestJson<RepoResponse>(input, `/repos/${repoPath}`);
const ref = source.ref ?? asString(repo.default_branch) ?? "main";
const branchRef = yield* requestJson<RefResponse>(
input,
`/repos/${repoPath}/git/ref/${encodeURIComponent(`heads/${ref}`)}`,
).pipe(Effect.result);
const commitPath =
Result.isSuccess(branchRef) && asString(branchRef.success.object?.sha)
? `/repos/${repoPath}/git/commits/${encodeURIComponent(String(branchRef.success.object?.sha))}`
: `/repos/${repoPath}/commits/${encodeURIComponent(ref)}`;
const commit = yield* requestJson<CommitResponse | GitCommitResponse>(input, commitPath);
const upstreamSha = asString(commit.sha);
const treeSha = commitTreeSha(commit);
if (!upstreamSha || !treeSha) {
return yield* new GitHubSourceError({
message: `GitHub commit ${ref} did not include a commit SHA and tree SHA`,
path: commitPath,
});
}
const tree = yield* requestJson<TreeResponse>(
input,
`/repos/${repoPath}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`,
);
if (tree.truncated === true) {
return yield* new GitHubSourceError({
message: "GitHub returned a truncated repository tree; custom tools source is too large",
path: `/repos/${repoPath}/git/trees/${treeSha}`,
});
}
const entries: (TreeEntry & { readonly path: string; readonly sha: string })[] = [];
const skipped: GitHubSkippedArtifact[] = [];
for (const entry of tree.tree ?? []) {
const classified = classifyTreeEntry(entry);
if (!classified) continue;
if (classified.kind === "fetch") entries.push(classified.entry);
else skipped.push(classified.skipped);
}
const treeLimitError = checkTreeLimits(entries);
if (treeLimitError) return yield* treeLimitError;
const files = new Map<string, string>();
for (const entry of entries) {
const path = String(entry.path);
const blob = yield* requestJson<BlobResponse>(
input,
`/repos/${repoPath}/git/blobs/${encodeURIComponent(String(entry.sha))}`,
);
files.set(path, yield* decodeBlob(path, blob));
}
const payloadLimitError = enforcePublishLimits(files);
if (payloadLimitError) return yield* payloadLimitError;
return {
files,
url: source.ref ? source.url : `https://github.com/${source.repo}`,
repo: source.repo,
ref,
upstreamSha,
description: yield* executorDescription(files),
skipped,
};
});
const publishErrorToSyncError = (publishFailure: PublishError): SyncErrorData => {
const stage = publishFailure.stage;
const message = publishFailure.message;
const diagnostics = publishFailure.diagnostics;
return { stage, message, diagnostics };
};
const sourceErrorToSyncError = (sourceFailure: GitHubSourceError): SyncErrorData => {
const message = sourceFailure.message;
const path = sourceFailure.path;
return {
stage: "source",
message,
diagnostics: path ? [{ path, message }] : [],
};
};
const sourceRef = (snapshot: GitHubSourceSnapshot): AppSourceRef => ({
kind: "github",
url: snapshot.url,
repo: snapshot.repo,
ref: snapshot.ref,
upstreamSha: snapshot.upstreamSha,
skipped: snapshot.skipped,
});
export const syncGitHubSource = (input: SyncGitHubSourceInput): Effect.Effect<GitHubSyncResult> =>
Effect.gen(function* () {
const fetched = yield* fetchGitHubSource(input).pipe(Effect.result);
if (Result.isFailure(fetched)) {
const error = fetched.failure;
return {
status: "failed",
tools: [],
skipped: [],
errors: [
Predicate.isTagged("PublishError")(error)
? publishErrorToSyncError(error)
: sourceErrorToSyncError(error),
],
} satisfies GitHubSyncResult;
}
const snapshot = fetched.success;
const current = input.tenant
? yield* input.runtime.getDescriptor(input.tenant, input.scope)
: yield* input.runtime.getDescriptor(input.scope);
if (
current?.source?.kind === "github" &&
current.source.repo === snapshot.repo &&
current.source.upstreamSha === snapshot.upstreamSha
) {
return {
status: "up-to-date",
upstreamSha: snapshot.upstreamSha,
tools: current.tools.map((tool) => tool.name),
skipped: [...snapshot.skipped, ...(current.skipped ?? [])],
} satisfies GitHubSyncResult;
}
const published = yield* input.runtime
.publish({
tenant: input.tenant,
scope: input.scope,
files: snapshot.files,
description: snapshot.description,
source: sourceRef(snapshot),
message: `sync ${snapshot.repo}@${snapshot.upstreamSha}`,
})
.pipe(Effect.result);
if (Result.isFailure(published)) {
return {
status: "failed",
upstreamSha: snapshot.upstreamSha,
tools: [],
skipped: snapshot.skipped,
errors: [publishErrorToSyncError(published.failure)],
} satisfies GitHubSyncResult;
}
return {
status: "published",
snapshotId: published.success.snapshotId,
upstreamSha: snapshot.upstreamSha,
tools: published.success.descriptor.tools.map((tool) => tool.name),
skipped: [...snapshot.skipped, ...published.success.skipped],
} satisfies GitHubSyncResult;
});
export { parseGitHubSourceUrl } from "./github-url";
export type { ParsedGitHubSourceUrl } from "./github-url";