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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { ConfigGeneratorYamlPreview } from "@/components/site/app-panels/config-generator-yaml-preview";
import type { GeneratorFormState } from "@/lib/config-generator-yaml";

describe("ConfigGeneratorYamlPreview", () => {
it("renders the serialized YAML, the filename label, and a copy button for a populated form state", () => {
const state: GeneratorFormState = {
gate: { aiReview: { combine: "consensus", provider: "anthropic" } },
};
render(<ConfigGeneratorYamlPreview formState={state} />);

expect(screen.getByText("Preview")).toBeTruthy();
// The filename appears both in the descriptive copy and the CodeBlock's own filename label.
expect(screen.getAllByText(".gittensory.yml").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/combine: consensus/)).toBeTruthy();
expect(screen.getByText(/provider: anthropic/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Copy code" })).toBeTruthy();
});

it("still renders a valid (header-only) preview for an empty form state", () => {
render(<ConfigGeneratorYamlPreview formState={{}} />);
expect(screen.getByText(/generated by the config generator/)).toBeTruthy();
expect(screen.queryByText(/aiReview/)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CodeBlock } from "@/components/site/primitives";
import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml";

/**
* Read-only `.gittensory.yml` preview for the config generator (#2210, part of #1683): renders the
* current GeneratorFormState as text via CodeBlock (built-in copy-to-clipboard) so the output is
* explicit and reviewable before a self-hoster saves or copies it. Purely presentational — field-group
* panels own collecting the form state.
*/
export function ConfigGeneratorYamlPreview({ formState }: { formState: GeneratorFormState }) {
return (
<section className="rounded-token border-hairline bg-card p-5">
<h2 className="font-display text-token-lg font-semibold">Preview</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
The exact <code className="font-mono">.gittensory.yml</code> this configuration would
produce. Nothing is saved until you copy it into your repo.
</p>
<div className="mt-4">
<CodeBlock code={formStateToYaml(formState)} filename=".gittensory.yml" />
</div>
</section>
);
}
79 changes: 79 additions & 0 deletions apps/gittensory-ui/src/lib/config-generator-yaml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";

import { formStateToYaml, type GeneratorFormState } from "@/lib/config-generator-yaml";

const HEADER = "# .gittensory.yml — generated by the config generator";

describe("formStateToYaml", () => {
it("serializes a full gate.aiReview config with every field set", () => {
const state: GeneratorFormState = {
gate: {
aiReview: {
combine: "consensus",
provider: "anthropic",
model: "claude-3-5-sonnet-latest",
},
},
};
expect(formStateToYaml(state)).toBe(
[
HEADER,
"gate:",
" aiReview:",
" combine: consensus",
" provider: anthropic",
" model: claude-3-5-sonnet-latest",
].join("\n"),
);
});

it("omits unset keys for a partial config (only provider set)", () => {
const state: GeneratorFormState = { gate: { aiReview: { provider: "openai" } } };
expect(formStateToYaml(state)).toBe(
[HEADER, "gate:", " aiReview:", " provider: openai"].join("\n"),
);
});

it("omits unset keys for a partial config (only combine and model set)", () => {
const state: GeneratorFormState = { gate: { aiReview: { combine: "single", model: "gpt-5" } } };
expect(formStateToYaml(state)).toBe(
[HEADER, "gate:", " aiReview:", " combine: single", " model: gpt-5"].join("\n"),
);
});

it("emits only the header for a fully empty state", () => {
expect(formStateToYaml({})).toBe(HEADER);
});

it("emits only the header when gate is present but aiReview is unset", () => {
expect(formStateToYaml({ gate: {} })).toBe(HEADER);
});

it("treats null fields the same as unset (omitted, not emitted as `null`)", () => {
const state: GeneratorFormState = {
gate: { aiReview: { combine: null, provider: null, model: null } },
};
expect(formStateToYaml(state)).toBe(HEADER);
});

it("treats an empty-string model as unset rather than emitting a blank value", () => {
const state: GeneratorFormState = { gate: { aiReview: { provider: "anthropic", model: "" } } };
expect(formStateToYaml(state)).toBe(
[HEADER, "gate:", " aiReview:", " provider: anthropic"].join("\n"),
);
});

it("quotes a model value containing YAML-special characters", () => {
const state: GeneratorFormState = { gate: { aiReview: { model: "claude: sonnet" } } };
expect(formStateToYaml(state)).toBe(
[HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify("claude: sonnet")}`].join("\n"),
);
});

it("quotes a model value with leading/trailing whitespace", () => {
const state: GeneratorFormState = { gate: { aiReview: { model: " claude " } } };
expect(formStateToYaml(state)).toBe(
[HEADER, "gate:", " aiReview:", ` model: ${JSON.stringify(" claude ")}`].join("\n"),
);
});
});
52 changes: 52 additions & 0 deletions apps/gittensory-ui/src/lib/config-generator-yaml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Config-generator YAML preview serializer (#2210, part of #1683). Pure form-state -> `.gittensory.yml`
// text builder — the reviewable-output half of the config generator; field-group panels (#2208 and
// siblings) own collecting GeneratorFormState, this module only turns it into text. Key names and
// nesting match the real manifest schema (packages/gittensory-engine/src/focus-manifest.ts's
// gateConfigToJson `gate.aiReview` shape, the same shape documented in this repo's own root
// .gittensory.yml under the commented-out `aiReview:` example) — no parallel schema. Every field is
// optional so a fresh/partial form never produces invalid output; unset keys are omitted entirely.

export type AiCombineStrategy = "single" | "consensus" | "synthesis";
export type AiProvider = "anthropic" | "openai";

export type GeneratorGateAiReviewState = {
combine?: AiCombineStrategy | null;
provider?: AiProvider | null;
model?: string | null;
};

export type GeneratorFormState = {
gate?: {
aiReview?: GeneratorGateAiReviewState;
};
};

const YAML_HEADER = "# .gittensory.yml — generated by the config generator";

/** Plain-scalar YAML value needs quoting when it would otherwise be ambiguous (a YAML special
* character, leading/trailing whitespace, or the empty string). Model names are free text, so this
* is a real correctness need, not defensive-for-its-own-sake. */
function yamlScalar(value: string): string {
const needsQuote = value === "" || value.trim() !== value || /[:#[\]{}&*!|>'"%@`,]/.test(value);
return needsQuote ? JSON.stringify(value) : value;
}

function yamlLine(indent: number, key: string, value: string): string {
return `${" ".repeat(indent)}${key}: ${yamlScalar(value)}`;
}

/** Serializes the `gate.aiReview` slice of GeneratorFormState into `.gittensory.yml` text, omitting
* every unset field/block so the preview is always valid YAML (including the empty-state case). */
export function formStateToYaml(state: GeneratorFormState): string {
const aiReview = state.gate?.aiReview;
const aiReviewLines: string[] = [];
if (aiReview?.combine) aiReviewLines.push(yamlLine(2, "combine", aiReview.combine));
if (aiReview?.provider) aiReviewLines.push(yamlLine(2, "provider", aiReview.provider));
if (aiReview?.model) aiReviewLines.push(yamlLine(2, "model", aiReview.model));

const lines = [YAML_HEADER];
if (aiReviewLines.length > 0) {
lines.push("gate:", " aiReview:", ...aiReviewLines);
}
return lines.join("\n");
}
Loading