Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 86 additions & 9 deletions lib/github/install-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import {
getRepo,
openPullRequest,
putFile,
searchRepoCode,
} from "./repos";

interface InstallInput {
token: string;
owner: string;
repo: string;
projectId: string;
/** Optional subdirectory inside a monorepo, e.g. "apps/web" or
* "sites/sh1pt.com". Canonical candidate paths get this prefix
* before being probed. Leave undefined for single-app repos. */
rootPath?: string;
}

export interface InstallResult {
Expand Down Expand Up @@ -78,6 +83,12 @@ function injectBeforeBodyClose(
return `${prefix}${indent} ${snippet}\n${indent}${content.slice(idx)}`;
}

/** Strip leading/trailing slashes so we can confidently join with "/". */
function normalizeRoot(p: string | undefined): string {
if (!p) return "";
return p.replace(/^\/+/, "").replace(/\/+$/, "");
}

export async function installTracker(input: InstallInput): Promise<InstallResult> {
const repoMeta = await getRepo({
token: input.token,
Expand All @@ -88,33 +99,99 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
const snippet = snippetFor(input.projectId);
const projectIdMarker = `data-site="${input.projectId}"`;

// 1. Find a candidate file that contains </body>.
let target: { path: string; sha: string; content: string } | null = null;
for (const path of CANDIDATES) {
const root = normalizeRoot(input.rootPath);
const candidates = root
? CANDIDATES.map((p) => `${root}/${p}`)
: CANDIDATES;

// Probe a path: returns the file if it has </body> AND doesn't already
// have our snippet; signals "already installed" if it does.
type ProbeResult =
| { kind: "hit"; file: { path: string; sha: string; content: string } }
| { kind: "already"; path: string }
| { kind: "miss" };
const probe = async (path: string): Promise<ProbeResult> => {
const file = await getFileContent({
token: input.token,
owner: input.owner,
repo: input.repo,
path,
ref: base,
});
if (!file) continue;
if (!file) return { kind: "miss" };
if (file.content.includes(projectIdMarker)) {
return { kind: "already", path: file.path };
}
if (/<\/body>/i.test(file.content)) {
return { kind: "hit", file };
}
return { kind: "miss" };
};

// 1. Try the canonical candidate list (optionally prefixed by rootPath).
let target: { path: string; sha: string; content: string } | null = null;
for (const path of candidates) {
const r = await probe(path);
if (r.kind === "already") {
return {
status: "noop",
path: file.path,
detail: `Tracker already installed at ${file.path}.`,
path: r.path,
detail: `Tracker already installed at ${r.path}.`,
};
}
if (/<\/body>/i.test(file.content)) {
target = file;
if (r.kind === "hit") {
target = r.file;
break;
}
}

// 2. Fallback: ask GitHub's code search for any file containing </body>
// in this repo. Handles monorepos (e.g. apps/web/app/layout.tsx,
// sites/foo/app/layout.tsx) and non-standard frameworks
// (SvelteKit src/app.html, Remix app/root.tsx, ...).
if (!target) {
try {
const hits = await searchRepoCode({
token: input.token,
owner: input.owner,
repo: input.repo,
query: "</body>",
});
// If a root path is configured, prefer hits inside it.
const ordered = root
? [
...hits.filter((h) => h.path.startsWith(root + "/")),
...hits.filter((h) => !h.path.startsWith(root + "/")),
]
: hits;
for (const hit of ordered) {
const r = await probe(hit.path);
if (r.kind === "already") {
return {
status: "noop",
path: r.path,
detail: `Tracker already installed at ${r.path}.`,
};
}
if (r.kind === "hit") {
target = r.file;
break;
}
}
} catch (err) {
// Code search failure is non-fatal — fall through to the error
// path below with a clearer message.
console.warn("[install-tracker] code search failed:", err);
}
}

if (!target) {
const probed = candidates.join(", ");
const hint = root
? `Looked under root "${root}". Try a different root path or open an issue with your repo layout.`
: "Monorepo? Set a root path on the project's Repos tab to point at the app directory (e.g. apps/web).";
throw new Error(
`No template file with </body> found. Looked in: ${CANDIDATES.join(", ")}.`,
`No template file with </body> found in ${input.owner}/${input.repo}. Probed canonical paths: ${probed}. ${hint}`,
);
}

Expand Down
49 changes: 49 additions & 0 deletions lib/github/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ export async function putFile(input: {
return (await res.json()) as PutFileResponse;
}

interface CodeSearchHit {
name: string;
path: string;
repository?: { full_name: string };
}

interface CodeSearchResponse {
total_count: number;
items: CodeSearchHit[];
}

/**
* Find files in a repo whose contents match a literal substring. Uses
* the GitHub Code Search API so we can locate template files in
* monorepos / non-standard layouts where the canonical paths miss.
*
* Note: the search index lags writes by minutes, so a freshly-pushed
* file may not appear immediately. For our use case (instrumenting an
* existing site) that's fine.
*/
export async function searchRepoCode(input: {
token: string;
owner: string;
repo: string;
query: string;
/** Optional path qualifier, e.g. "*.tsx" or "app/". */
extension?: string;
perPage?: number;
}): Promise<CodeSearchHit[]> {
// The search API needs the substring inside quotes when it contains
// special characters. Build q="</body>" repo:owner/name
const q = [
`"${input.query}"`,
`repo:${input.owner}/${input.repo}`,
input.extension ? `extension:${input.extension}` : "",
]
.filter(Boolean)
.join(" ");
const res = await gh(
`/search/code?q=${encodeURIComponent(q)}&per_page=${input.perPage ?? 30}`,
{ token: input.token },
);
if (!res.ok) {
throw new Error(`searchRepoCode ${res.status}: ${await res.text()}`);
}
const body = (await res.json()) as CodeSearchResponse;
return body.items ?? [];
}

interface PullRequest {
number: number;
html_url: string;
Expand Down
Loading