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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ coverage/
site/.vitepress/cache/
!migrations/*.sql
apps/gittensory-ui/public/downloads/gittensory-extension.zip
.worker-configuration.gen-check.d.ts
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"command-reference:check": "node scripts/gen-command-reference.mjs --check",
"selfhost:validate-observability": "node scripts/validate-observability-configs.mjs",
"selfhost:config-lint": "tsx scripts/gittensory-config-lint.ts",
"cf-typegen": "wrangler types && perl -pi -e 's/[[:blank:]]+$//' worker-configuration.d.ts",
"cf-typegen:check": "wrangler types --check",
"cf-typegen": "node scripts/gen-cf-typegen.mjs",
"cf-typegen:check": "node scripts/gen-cf-typegen.mjs --check",
"db:migrate:local": "wrangler d1 migrations apply gittensory --local",
"db:migrate:remote": "wrangler d1 migrations apply gittensory --remote",
"drizzle:generate": "drizzle-kit generate",
Expand Down
5 changes: 5 additions & 0 deletions scripts/gen-cf-typegen.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const OUTPUT_PATH: string;

export function formatCfTypegenOutput(source: string): string;

export function genCfTypegen(options?: { check?: boolean }): { changed: boolean | undefined };
72 changes: 72 additions & 0 deletions scripts/gen-cf-typegen.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

// Cloudflare's own `wrangler types` output packs every declared env var into ONE long single-line
// `Pick<Cloudflare.Env, "A" | "B" | ...>>` union (the generated ProcessEnv interface). Two independent PRs
// that each add a DIFFERENT new var to wrangler.jsonc's `vars` regenerate that SAME line with their own
// insertion -- git's line-based 3-way merge sees "the same line changed differently in both branches" and
// reports a conflict on worker-configuration.d.ts, even though the two additions are logically unrelated
// (hit repeatedly across the #1667 incident-followup PRs). This wrapper still regenerates via the real
// `wrangler types` CLI (so it can never drift from wrangler's own output), then reformats JUST that one
// union onto one sorted entry per line -- two PRs adding different vars now land on different, non-adjacent
// lines and merge cleanly like every other interface body in this file already does.
export const OUTPUT_PATH = "worker-configuration.d.ts";
const TEMP_PATH = ".worker-configuration.gen-check.d.ts";
const PICK_RE = /Pick<Cloudflare\.Env, ((?:"[^"]+" \| )*"[^"]+")>>/;

/** Pure: strip trailing whitespace (wrangler's own raw output convention this repo has always normalized),
* normalize wrangler's own "Generated by ... running `wrangler types [path]`" header comment (it echoes
* whatever path argument was passed, which check mode intentionally sets to a different temp path than
* the real OUTPUT_PATH — without this, check mode would always report false staleness on that line alone),
* then reformat the single-line ProcessEnv Pick<Cloudflare.Env, ...> union into one sorted entry per line. */
export function formatCfTypegenOutput(source) {
const stripped = source
.replace(/[ \t]+$/gm, "")
.replace(/(Generated by Wrangler by running `wrangler types)[^`]*`/, "$1`");
const match = stripped.match(PICK_RE);
if (!match) {
throw new Error(`Could not find the ProcessEnv Pick<Cloudflare.Env, ...> union in the generated output to reformat.`);
}
const keys = match[1]
.split(" | ")
.map((quoted) => quoted.slice(1, -1))
.sort((a, b) => a.localeCompare(b));
const lines = keys.map((key) => `\t\t| "${key}"`).join("\n");
return stripped.replace(PICK_RE, `Pick<Cloudflare.Env,\n${lines}\n\t>>`);
}

export function genCfTypegen({ check = false } = {}) {
const target = check ? TEMP_PATH : OUTPUT_PATH;
try {
execFileSync("wrangler", ["types", target], { stdio: "inherit" });
const generated = formatCfTypegenOutput(readFileSync(target, "utf8"));
if (!check) {
writeFileSync(OUTPUT_PATH, generated);
return { changed: undefined };
}
const current = readFileSync(OUTPUT_PATH, "utf8");
return { changed: current !== generated };
} finally {
if (check && existsSync(TEMP_PATH)) unlinkSync(TEMP_PATH);
}
}

function main(argv) {
const check = argv.includes("--check");
const { changed } = genCfTypegen({ check });
if (check) {
if (changed) {
process.stderr.write(`gen-cf-typegen: ${OUTPUT_PATH} is stale; run npm run cf-typegen.\n`);
process.exit(1);
}
process.stdout.write(`gen-cf-typegen: ${OUTPUT_PATH} is up to date.\n`);
return;
}
process.stdout.write(`gen-cf-typegen: wrote ${OUTPUT_PATH}.\n`);
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main(process.argv.slice(2));
}
2 changes: 1 addition & 1 deletion test/unit/ci-cf-typegen-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("cf-typegen staleness guard (#2557)", () => {
const pkg = record(JSON.parse(readFileSync("package.json", "utf8")), "package.json");
const scripts = record(pkg.scripts, "package.json.scripts");

expect(scripts["cf-typegen:check"]).toBe("wrangler types --check");
expect(scripts["cf-typegen:check"]).toBe("node scripts/gen-cf-typegen.mjs --check");
expect(String(scripts["test:ci"])).toContain("npm run cf-typegen:check");
// Must run before typecheck, mirroring db:migrations:check's position -- a drift-check failure should
// surface before the more expensive type/test/build steps run.
Expand Down
107 changes: 107 additions & 0 deletions test/unit/ci-cf-typegen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { formatCfTypegenOutput } from "../../scripts/gen-cf-typegen.mjs";

// #1667-followup: wrangler's own `wrangler types` output packs every declared env var into ONE long
// single-line `Pick<Cloudflare.Env, "A" | "B" | ...>>` union. Two independent PRs that each add a DIFFERENT
// new var regenerate that SAME line with their own insertion -- git's line-based 3-way merge sees "the same
// line changed differently in both branches" and reports a real merge conflict on worker-configuration.d.ts,
// even though the two additions are logically unrelated. This hit several same-day PRs in a row. The fix:
// reformat that one union onto one sorted entry per line, so two independent additions land on different,
// non-adjacent lines and merge cleanly like every other interface body in this file already does.
function rawFixture(vars: string[]): string {
const union = vars.map((v) => `"${v}"`).join(" | ");
return [
"/* eslint-disable */",
"// Generated by Wrangler by running `wrangler types` (hash: deadbeef)",
"// Runtime types generated with workerd@1.0.0 2026-01-01 nodejs_compat",
"interface Env {}",
"declare namespace NodeJS {",
`\tinterface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, ${union}>> {}`,
"}",
"",
].join("\n");
}

describe("gen-cf-typegen: formatCfTypegenOutput", () => {
it("reformats the single-line Pick<Cloudflare.Env, ...> union into one sorted entry per line", () => {
const out = formatCfTypegenOutput(rawFixture(["ZEBRA", "ALPHA", "MID"]));
expect(out).toContain('Pick<Cloudflare.Env,\n\t\t| "ALPHA"\n\t\t| "MID"\n\t\t| "ZEBRA"\n\t>>');
});

it("sorts regardless of the original declaration order", () => {
const outAsc = formatCfTypegenOutput(rawFixture(["A", "B", "C"]));
const outDesc = formatCfTypegenOutput(rawFixture(["C", "B", "A"]));
expect(outAsc).toBe(outDesc);
});

it("normalizes the wrangler header's echoed path argument so a different invocation path does not read as a content change", () => {
const bare = formatCfTypegenOutput(rawFixture(["A"]));
const withPath = formatCfTypegenOutput(rawFixture(["A"]).replace("wrangler types` (hash", "wrangler types .some-temp-path.d.ts` (hash"));
expect(bare).toBe(withPath);
});

it("strips trailing whitespace from every line", () => {
const withTrailingWhitespace = rawFixture(["A"]).replace("interface Env {}", "interface Env {} ");
const out = formatCfTypegenOutput(withTrailingWhitespace);
expect(out).not.toMatch(/ +\n/);
expect(out).toContain("interface Env {}\n");
});

it("throws when the ProcessEnv Pick<Cloudflare.Env, ...> union is not found", () => {
expect(() => formatCfTypegenOutput("interface Env {}\n")).toThrow(/Could not find the ProcessEnv/);
});

it("REGRESSION: two independent single-var additions no longer conflict under a real git 3-way merge", () => {
const dir = mkdtempSync(join(tmpdir(), "cf-typegen-merge-"));
try {
const base = formatCfTypegenOutput(rawFixture(["ALPHA", "MID", "ZEBRA"]));
const ours = formatCfTypegenOutput(rawFixture(["ALPHA", "BRAVO", "MID", "ZEBRA"])); // adds BRAVO
const theirs = formatCfTypegenOutput(rawFixture(["ALPHA", "MID", "YANKEE", "ZEBRA"])); // adds YANKEE
const basePath = join(dir, "base.d.ts");
const oursPath = join(dir, "ours.d.ts");
const theirsPath = join(dir, "theirs.d.ts");
writeFileSync(basePath, base);
writeFileSync(oursPath, ours);
writeFileSync(theirsPath, theirs);

const merged = execFileSync("git", ["merge-file", "-p", oursPath, basePath, theirsPath], { encoding: "utf8" });
expect(merged).not.toContain("<<<<<<<");
expect(merged).toContain('| "BRAVO"');
expect(merged).toContain('| "YANKEE"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("BASELINE: the SAME two additions DO conflict on the original unformatted single-line union", () => {
const dir = mkdtempSync(join(tmpdir(), "cf-typegen-merge-baseline-"));
try {
const base = rawFixture(["ALPHA", "MID", "ZEBRA"]);
const ours = rawFixture(["ALPHA", "BRAVO", "MID", "ZEBRA"]);
const theirs = rawFixture(["ALPHA", "MID", "YANKEE", "ZEBRA"]);
const basePath = join(dir, "base.d.ts");
const oursPath = join(dir, "ours.d.ts");
const theirsPath = join(dir, "theirs.d.ts");
writeFileSync(basePath, base);
writeFileSync(oursPath, ours);
writeFileSync(theirsPath, theirs);

let conflicted = false;
let merged = "";
try {
merged = execFileSync("git", ["merge-file", "-p", oursPath, basePath, theirsPath], { encoding: "utf8" });
} catch (error) {
conflicted = true;
merged = String((error as { stdout?: string }).stdout ?? "");
}
expect(conflicted).toBe(true);
expect(merged).toContain("<<<<<<<");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
41 changes: 40 additions & 1 deletion worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,46 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "GITHUB_APP_ID" | "GITHUB_APP_SLUG" | "GITHUB_OAUTH_CLIENT_ID" | "GITHUB_WEBHOOK_MAX_BODY_BYTES" | "GITTENSOR_UPSTREAM_REPO" | "GITTENSOR_UPSTREAM_REF" | "GITTENSOR_REGISTRY_URL" | "SCORING_TIME_DECAY_ENABLED" | "GITTENSORY_AUTO_FILE_DRIFT_ISSUES" | "GITTENSORY_DRIFT_ISSUE_REPO" | "PUBLIC_API_ORIGIN" | "PUBLIC_SITE_ORIGIN" | "ADMIN_GITHUB_LOGINS" | "GITTENSORY_REVIEW_UNIFIED_COMMENT" | "GITTENSORY_REVIEW_INLINE_COMMENTS" | "GITTENSORY_REVIEW_TEST_GENERATION" | "GITTENSORY_REVIEW_SAFETY" | "GITTENSORY_REVIEW_SCREENSHOTS" | "GITTENSORY_REVIEW_GROUNDING" | "GITTENSORY_REVIEW_REPUTATION" | "GITTENSORY_REVIEW_OPS" | "GITTENSORY_SWEEP_WATCHDOG" | "GITTENSORY_PR_RECONCILIATION" | "GITTENSORY_REVIEW_RAG" | "GITTENSORY_REVIEW_IMPACT_MAP" | "GITTENSORY_REVIEW_CULTURE_PROFILE" | "GITTENSORY_REVIEW_MEMORY" | "GITTENSORY_REVIEW_CONTENT_LANE" | "GITTENSORY_REVIEW_SELFTUNE" | "GITHUB_STATUS_ROLLUP_GRAPHQL" | "GITTENSORY_REVIEW_PLANNER" | "GITTENSORY_REVIEW_DRAFT" | "GITTENSORY_REVIEW_PARITY_AUDIT" | "GITTENSORY_REVIEW_REPOS" | "GITTENSORY_PUBLIC_STATS" | "GITTENSORY_PUBLIC_STATS_REPOS" | "GITTENSORY_DUPLICATE_WINNER" | "GITTENSORY_OPEN_PR_FILE_COLLISION">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env,
| "ADMIN_GITHUB_LOGINS"
| "GITHUB_APP_ID"
| "GITHUB_APP_SLUG"
| "GITHUB_OAUTH_CLIENT_ID"
| "GITHUB_STATUS_ROLLUP_GRAPHQL"
| "GITHUB_WEBHOOK_MAX_BODY_BYTES"
| "GITTENSOR_REGISTRY_URL"
| "GITTENSOR_UPSTREAM_REF"
| "GITTENSOR_UPSTREAM_REPO"
| "GITTENSORY_AUTO_FILE_DRIFT_ISSUES"
| "GITTENSORY_DRIFT_ISSUE_REPO"
| "GITTENSORY_DUPLICATE_WINNER"
| "GITTENSORY_OPEN_PR_FILE_COLLISION"
| "GITTENSORY_PR_RECONCILIATION"
| "GITTENSORY_PUBLIC_STATS"
| "GITTENSORY_PUBLIC_STATS_REPOS"
| "GITTENSORY_REVIEW_CONTENT_LANE"
| "GITTENSORY_REVIEW_CULTURE_PROFILE"
| "GITTENSORY_REVIEW_DRAFT"
| "GITTENSORY_REVIEW_GROUNDING"
| "GITTENSORY_REVIEW_IMPACT_MAP"
| "GITTENSORY_REVIEW_INLINE_COMMENTS"
| "GITTENSORY_REVIEW_MEMORY"
| "GITTENSORY_REVIEW_OPS"
| "GITTENSORY_REVIEW_PARITY_AUDIT"
| "GITTENSORY_REVIEW_PLANNER"
| "GITTENSORY_REVIEW_RAG"
| "GITTENSORY_REVIEW_REPOS"
| "GITTENSORY_REVIEW_REPUTATION"
| "GITTENSORY_REVIEW_SAFETY"
| "GITTENSORY_REVIEW_SCREENSHOTS"
| "GITTENSORY_REVIEW_SELFTUNE"
| "GITTENSORY_REVIEW_TEST_GENERATION"
| "GITTENSORY_REVIEW_UNIFIED_COMMENT"
| "GITTENSORY_SWEEP_WATCHDOG"
| "PUBLIC_API_ORIGIN"
| "PUBLIC_SITE_ORIGIN"
| "SCORING_TIME_DECAY_ENABLED"
>> {}
}

// Begin runtime types
Expand Down
Loading