Skip to content

Commit 20a9d88

Browse files
authored
feat(kanban): add phase 6 gitops enforcement (#12)
1 parent c8f2c72 commit 20a9d88

7 files changed

Lines changed: 1246 additions & 94 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect";
3+
import { assert, describe, it } from "@effect/vitest";
4+
5+
import { ServerConfig } from "../config.ts";
6+
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
7+
import * as VcsProcess from "../vcs/VcsProcess.ts";
8+
import * as GitStatusProvider from "./GitStatusProvider.ts";
9+
10+
const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
11+
prefix: "t3-kanban-git-status-",
12+
});
13+
14+
const GitLayer = GitVcsDriver.layer.pipe(
15+
Layer.provide(ServerConfigLayer),
16+
Layer.provideMerge(VcsProcess.layer),
17+
Layer.provideMerge(NodeServices.layer),
18+
);
19+
const ProviderLayer = GitStatusProvider.layer.pipe(Layer.provide(GitLayer));
20+
const TestLayer = Layer.mergeAll(GitLayer, ProviderLayer);
21+
22+
const policy = {
23+
protectedBranches: ["main", "release/*"],
24+
allowedWorkBranchPrefixes: ["feature/", "fix/", "chore/", "docs/"],
25+
destructiveActionsRequireSecondConfirmation: true,
26+
};
27+
28+
function makeTempDir(
29+
prefix: string,
30+
): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
31+
return Effect.gen(function* () {
32+
const fileSystem = yield* FileSystem.FileSystem;
33+
return yield* fileSystem.makeTempDirectoryScoped({ prefix });
34+
});
35+
}
36+
37+
function writeFile(
38+
cwd: string,
39+
relativePath: string,
40+
content: string,
41+
): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
42+
return Effect.gen(function* () {
43+
const fileSystem = yield* FileSystem.FileSystem;
44+
const path = yield* Path.Path;
45+
const absolutePath = path.join(cwd, relativePath);
46+
yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
47+
yield* fileSystem.writeFileString(absolutePath, content);
48+
});
49+
}
50+
51+
function runGit(cwd: string, args: ReadonlyArray<string>) {
52+
return Effect.gen(function* () {
53+
const git = yield* GitVcsDriver.GitVcsDriver;
54+
yield* git.execute({
55+
operation: "KanbanGitStatusProvider.test.git",
56+
cwd,
57+
args,
58+
timeoutMs: 10_000,
59+
});
60+
});
61+
}
62+
63+
function initRepo() {
64+
return Effect.gen(function* () {
65+
const repoDir = yield* makeTempDir("kanban-git-status-");
66+
yield* runGit(repoDir, ["init", "-b", "main"]);
67+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
68+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
69+
yield* writeFile(repoDir, "README.md", "initial\n");
70+
yield* runGit(repoDir, ["add", "README.md"]);
71+
yield* runGit(repoDir, ["commit", "-m", "initial"]);
72+
return repoDir;
73+
});
74+
}
75+
76+
describe("KanbanGitStatusProvider", () => {
77+
it.layer(TestLayer)("reads branch, staged, unstaged, and untracked status", (it) => {
78+
it.effect("maps real git state into the Kanban status contract", () =>
79+
Effect.gen(function* () {
80+
const provider = yield* GitStatusProvider.KanbanGitStatusProvider;
81+
const repoDir = yield* initRepo();
82+
yield* runGit(repoDir, ["checkout", "-b", "feature/gitops"]);
83+
yield* writeFile(repoDir, "README.md", "initial\nunstaged\n");
84+
yield* writeFile(repoDir, "src/staged.ts", "export const staged = true;\n");
85+
yield* runGit(repoDir, ["add", "src/staged.ts"]);
86+
yield* writeFile(repoDir, "notes/untracked.md", "untracked\n");
87+
88+
const status = yield* provider.readStatus({
89+
repoId: "repo-1",
90+
cwd: repoDir,
91+
policy,
92+
});
93+
94+
assert.equal(status.branch, "feature/gitops");
95+
assert.equal(status.isRepo, true);
96+
assert.equal(
97+
status.files.some((file) => file.path === "README.md" && file.status === "unstaged"),
98+
true,
99+
);
100+
assert.equal(
101+
status.files.some((file) => file.path === "src/staged.ts" && file.status === "staged"),
102+
true,
103+
);
104+
assert.equal(
105+
status.files.some(
106+
(file) => file.path === "notes/untracked.md" && file.status === "untracked",
107+
),
108+
true,
109+
);
110+
assert.equal(
111+
status.policyViolations?.some((violation) => violation.kind === "missing-upstream"),
112+
true,
113+
);
114+
}),
115+
);
116+
117+
it.effect("flags dirty protected branches", () =>
118+
Effect.gen(function* () {
119+
const provider = yield* GitStatusProvider.KanbanGitStatusProvider;
120+
const repoDir = yield* initRepo();
121+
yield* writeFile(repoDir, "README.md", "dirty on main\n");
122+
123+
const status = yield* provider.readStatus({
124+
repoId: "repo-1",
125+
cwd: repoDir,
126+
policy,
127+
});
128+
129+
assert.equal(status.branch, "main");
130+
assert.equal(
131+
status.policyViolations?.some(
132+
(violation) =>
133+
violation.kind === "protected-branch" && violation.severity === "blocked",
134+
),
135+
true,
136+
);
137+
}),
138+
);
139+
140+
it.effect("reads diffs and gates stage/unstage actions on confirmation", () =>
141+
Effect.gen(function* () {
142+
const provider = yield* GitStatusProvider.KanbanGitStatusProvider;
143+
const repoDir = yield* initRepo();
144+
yield* runGit(repoDir, ["checkout", "-b", "feature/stage-actions"]);
145+
yield* writeFile(repoDir, "README.md", "initial\nchanged\n");
146+
147+
const diff = yield* provider.readFileDiff({
148+
repoId: "repo-1",
149+
cwd: repoDir,
150+
path: "README.md",
151+
status: "unstaged",
152+
});
153+
assert.equal(diff.truncated, false);
154+
assert.equal(diff.diff.includes("+changed"), true);
155+
156+
const blocked = yield* provider.stageFiles({
157+
repoId: "repo-1",
158+
cwd: repoDir,
159+
paths: ["README.md"],
160+
confirmed: false,
161+
});
162+
assert.equal(blocked.status, "blocked");
163+
164+
const staged = yield* provider.stageFiles({
165+
repoId: "repo-1",
166+
cwd: repoDir,
167+
paths: ["README.md"],
168+
confirmed: true,
169+
});
170+
assert.equal(staged.status, "applied");
171+
172+
let status = yield* provider.readStatus({ repoId: "repo-1", cwd: repoDir, policy });
173+
assert.equal(
174+
status.files.some((file) => file.path === "README.md" && file.status === "staged"),
175+
true,
176+
);
177+
178+
const unstaged = yield* provider.unstageFiles({
179+
repoId: "repo-1",
180+
cwd: repoDir,
181+
paths: ["README.md"],
182+
confirmed: true,
183+
});
184+
assert.equal(unstaged.status, "applied");
185+
186+
status = yield* provider.readStatus({ repoId: "repo-1", cwd: repoDir, policy });
187+
assert.equal(
188+
status.files.some((file) => file.path === "README.md" && file.status === "unstaged"),
189+
true,
190+
);
191+
}),
192+
);
193+
194+
it.effect("normalizes renamed paths for diffs and file actions", () =>
195+
Effect.gen(function* () {
196+
const provider = yield* GitStatusProvider.KanbanGitStatusProvider;
197+
const repoDir = yield* initRepo();
198+
yield* runGit(repoDir, ["checkout", "-b", "feature/rename-status"]);
199+
yield* runGit(repoDir, ["mv", "README.md", "README-renamed.md"]);
200+
201+
const status = yield* provider.readStatus({ repoId: "repo-1", cwd: repoDir, policy });
202+
const renamed = status.files.find((file) => file.change === "renamed");
203+
204+
assert.equal(renamed?.path, "README-renamed.md");
205+
assert.equal(renamed?.sourcePath, "README.md");
206+
207+
const diff = yield* provider.readFileDiff({
208+
repoId: "repo-1",
209+
cwd: repoDir,
210+
path: renamed?.path ?? "README-renamed.md",
211+
status: "staged",
212+
});
213+
assert.equal(diff.diff.includes("README-renamed.md"), true);
214+
215+
const unstaged = yield* provider.unstageFiles({
216+
repoId: "repo-1",
217+
cwd: repoDir,
218+
paths: [renamed?.path ?? "README-renamed.md"],
219+
confirmed: true,
220+
});
221+
assert.equal(unstaged.status, "applied");
222+
}),
223+
);
224+
225+
it.effect("reports release and tag readiness gates", () =>
226+
Effect.gen(function* () {
227+
const provider = yield* GitStatusProvider.KanbanGitStatusProvider;
228+
const repoDir = yield* initRepo();
229+
yield* runGit(repoDir, ["tag", "v0.1.0"]);
230+
yield* runGit(repoDir, ["checkout", "-b", "release/0.2.0"]);
231+
yield* writeFile(repoDir, "docs/product/release-notes.md", "Release notes\n");
232+
yield* runGit(repoDir, ["add", "docs/product/release-notes.md"]);
233+
yield* runGit(repoDir, ["commit", "-m", "release notes"]);
234+
235+
const readiness = yield* provider.readReleaseReadiness({
236+
cwd: repoDir,
237+
policy,
238+
releaseNotesPath: "docs/product/release-notes.md",
239+
targetTag: "v0.2.0",
240+
providerStatuses: [{ id: "gate-ci", label: "CI", status: "passing" }],
241+
});
242+
243+
assert.equal(readiness.branch, "release/0.2.0");
244+
assert.equal(readiness.latestTag, "v0.1.0");
245+
assert.equal(readiness.targetTag, "v0.2.0");
246+
assert.equal(
247+
readiness.gates.every((gate) => gate.status === "passing"),
248+
true,
249+
);
250+
251+
yield* runGit(repoDir, ["tag", "v0.2.0"]);
252+
const blocked = yield* provider.readReleaseReadiness({
253+
cwd: repoDir,
254+
policy,
255+
releaseNotesPath: "docs/product/release-notes.md",
256+
targetTag: "v0.2.0",
257+
});
258+
assert.equal(
259+
blocked.gates.some(
260+
(gate) => gate.id === "gate-tag-readiness" && gate.status === "blocked",
261+
),
262+
true,
263+
);
264+
}),
265+
);
266+
});
267+
});

0 commit comments

Comments
 (0)