Skip to content

Commit cba9403

Browse files
committed
feat(kanban): add phase 7 product artifacts
1 parent 20a9d88 commit cba9403

8 files changed

Lines changed: 846 additions & 18 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect";
3+
import { ChildProcessSpawner } from "effect/unstable/process";
4+
import { assert, describe, expect, it, vi, afterEach } from "@effect/vitest";
5+
6+
import { ServerConfig } from "../config.ts";
7+
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8+
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
9+
import type * as VcsProcess from "../vcs/VcsProcess.ts";
10+
import * as VcsProcessLayer from "../vcs/VcsProcess.ts";
11+
import * as ProductArtifactsProvider from "./ProductArtifactsProvider.ts";
12+
13+
const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
14+
prefix: "t3-kanban-product-artifacts-",
15+
});
16+
17+
const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
18+
exitCode: ChildProcessSpawner.ExitCode(0),
19+
stdout,
20+
stderr: "",
21+
stdoutTruncated: false,
22+
stderrTruncated: false,
23+
});
24+
25+
const execute = vi.fn<GitHubCli.GitHubCliShape["execute"]>();
26+
27+
const GitLayer = GitVcsDriver.layer.pipe(
28+
Layer.provide(ServerConfigLayer),
29+
Layer.provideMerge(VcsProcessLayer.layer),
30+
Layer.provideMerge(NodeServices.layer),
31+
);
32+
33+
const ProviderLayer = ProductArtifactsProvider.layer.pipe(
34+
Layer.provide(GitLayer),
35+
Layer.provide(
36+
Layer.mock(GitHubCli.GitHubCli)({
37+
execute,
38+
listOpenPullRequests: vi.fn(),
39+
getPullRequest: vi.fn(),
40+
getRepositoryCloneUrls: vi.fn(),
41+
createRepository: vi.fn(),
42+
createPullRequest: vi.fn(),
43+
getDefaultBranch: vi.fn(),
44+
checkoutPullRequest: vi.fn(),
45+
}),
46+
),
47+
);
48+
49+
const TestLayer = Layer.mergeAll(GitLayer, ProviderLayer);
50+
51+
afterEach(() => {
52+
execute.mockReset();
53+
});
54+
55+
function makeTempDir(
56+
prefix: string,
57+
): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
58+
return Effect.gen(function* () {
59+
const fileSystem = yield* FileSystem.FileSystem;
60+
return yield* fileSystem.makeTempDirectoryScoped({ prefix });
61+
});
62+
}
63+
64+
function writeFile(
65+
cwd: string,
66+
relativePath: string,
67+
content: string,
68+
): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
69+
return Effect.gen(function* () {
70+
const fileSystem = yield* FileSystem.FileSystem;
71+
const path = yield* Path.Path;
72+
const absolutePath = path.join(cwd, relativePath);
73+
yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
74+
yield* fileSystem.writeFileString(absolutePath, content);
75+
});
76+
}
77+
78+
function runGit(cwd: string, args: ReadonlyArray<string>) {
79+
return Effect.gen(function* () {
80+
const git = yield* GitVcsDriver.GitVcsDriver;
81+
yield* git.execute({
82+
operation: "ProductArtifactsProvider.test.git",
83+
cwd,
84+
args,
85+
timeoutMs: 10_000,
86+
});
87+
});
88+
}
89+
90+
function initRepo() {
91+
return Effect.gen(function* () {
92+
const repoDir = yield* makeTempDir("kanban-product-artifacts-");
93+
yield* runGit(repoDir, ["init", "-b", "main"]);
94+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
95+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
96+
yield* writeFile(repoDir, "docs/product/overview.md", "# Overview\n\nSynthetic notes.\n");
97+
yield* writeFile(repoDir, "docs/product/nested/brief.md", "# Brief\n\nNested notes.\n");
98+
yield* writeFile(repoDir, "README.md", "initial\n");
99+
yield* runGit(repoDir, ["add", "."]);
100+
yield* runGit(repoDir, ["commit", "-m", "initial"]);
101+
yield* runGit(repoDir, ["checkout", "-b", "feature/product-artifacts"]);
102+
return repoDir;
103+
});
104+
}
105+
106+
describe("ProductArtifactsProvider", () => {
107+
it.layer(TestLayer)("browses and previews Markdown artifacts under docs/product", (it) => {
108+
it.effect("lists product Markdown files with clean status", () =>
109+
Effect.gen(function* () {
110+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
111+
const repoDir = yield* initRepo();
112+
113+
const artifacts = yield* provider.listArtifacts({
114+
repoId: "repo-1",
115+
cwd: repoDir,
116+
});
117+
118+
expect(artifacts.map((artifact) => artifact.path)).toEqual([
119+
"docs/product/nested/brief.md",
120+
"docs/product/overview.md",
121+
]);
122+
expect(artifacts.every((artifact) => artifact.status === "clean")).toBe(true);
123+
124+
const content = yield* provider.readArtifact({
125+
repoId: "repo-1",
126+
cwd: repoDir,
127+
path: "docs/product/overview.md",
128+
});
129+
assert.equal(content.title, "Overview");
130+
assert.equal(content.preview.includes("Synthetic notes."), true);
131+
}),
132+
);
133+
134+
it.effect("confines reads and writes to docs/product Markdown files", () =>
135+
Effect.gen(function* () {
136+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
137+
const repoDir = yield* initRepo();
138+
139+
const outside = yield* Effect.exit(
140+
provider.readArtifact({
141+
repoId: "repo-1",
142+
cwd: repoDir,
143+
path: "docs/product/../tasks/plan.md",
144+
}),
145+
);
146+
const nonMarkdown = yield* Effect.exit(
147+
provider.writeArtifact({
148+
repoId: "repo-1",
149+
cwd: repoDir,
150+
path: "docs/product/notes.txt",
151+
content: "not markdown",
152+
confirmed: true,
153+
}),
154+
);
155+
156+
assert.equal(outside._tag, "Failure");
157+
assert.equal(nonMarkdown._tag, "Failure");
158+
}),
159+
);
160+
161+
it.effect("blocks dirty file conflicts before writing", () =>
162+
Effect.gen(function* () {
163+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
164+
const repoDir = yield* initRepo();
165+
yield* writeFile(repoDir, "docs/product/overview.md", "# Overview\n\nDirty edit.\n");
166+
167+
const result = yield* provider.writeArtifact({
168+
repoId: "repo-1",
169+
cwd: repoDir,
170+
path: "docs/product/overview.md",
171+
content: "# Overview\n\nNew edit.\n",
172+
confirmed: true,
173+
});
174+
175+
assert.equal(result.status, "blocked");
176+
assert.equal(result.message.includes("dirty"), true);
177+
}),
178+
);
179+
180+
it.effect("writes clean artifacts and posts concise linked issue comments", () =>
181+
Effect.gen(function* () {
182+
execute.mockReturnValueOnce(Effect.succeed(processOutput("commented\n")));
183+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
184+
const repoDir = yield* initRepo();
185+
186+
const result = yield* provider.writeArtifact({
187+
repoId: "repo-1",
188+
cwd: repoDir,
189+
path: "docs/product/overview.md",
190+
content: "# Overview\n\nUpdated through provider.\n",
191+
confirmed: true,
192+
linkedRepository: "MohAnghabo/kanban-console",
193+
linkedIssueNumber: 43,
194+
});
195+
196+
assert.equal(result.status, "applied");
197+
assert.equal(result.commentTarget, "issue#43");
198+
expect(execute).toHaveBeenCalledWith({
199+
cwd: repoDir,
200+
args: [
201+
"issue",
202+
"comment",
203+
"43",
204+
"--repo",
205+
"MohAnghabo/kanban-console",
206+
"--body",
207+
expect.stringContaining("Raw diff and command output intentionally omitted."),
208+
],
209+
timeoutMs: 30_000,
210+
});
211+
212+
const content = yield* provider.readArtifact({
213+
repoId: "repo-1",
214+
cwd: repoDir,
215+
path: "docs/product/overview.md",
216+
});
217+
assert.equal(content.content.includes("Updated through provider."), true);
218+
}),
219+
);
220+
221+
it.effect("keeps the artifact write result explicit when comment posting fails", () =>
222+
Effect.gen(function* () {
223+
execute.mockReturnValueOnce(
224+
Effect.fail(
225+
new GitHubCli.GitHubCliError({
226+
operation: "execute",
227+
detail: "GitHub CLI is not authenticated.",
228+
}),
229+
),
230+
);
231+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
232+
const repoDir = yield* initRepo();
233+
234+
const result = yield* provider.writeArtifact({
235+
repoId: "repo-1",
236+
cwd: repoDir,
237+
path: "docs/product/overview.md",
238+
content: "# Overview\n\nUpdated without comment.\n",
239+
confirmed: true,
240+
linkedRepository: "MohAnghabo/kanban-console",
241+
linkedIssueNumber: 43,
242+
});
243+
244+
assert.equal(result.status, "applied");
245+
assert.equal(result.commentTarget, undefined);
246+
assert.equal(result.message.includes("comment posting failed"), true);
247+
248+
const content = yield* provider.readArtifact({
249+
repoId: "repo-1",
250+
cwd: repoDir,
251+
path: "docs/product/overview.md",
252+
});
253+
assert.equal(content.content.includes("Updated without comment."), true);
254+
}),
255+
);
256+
257+
it.effect("blocks confirmed writes on protected branches", () =>
258+
Effect.gen(function* () {
259+
const provider = yield* ProductArtifactsProvider.ProductArtifactsProvider;
260+
const repoDir = yield* initRepo();
261+
yield* runGit(repoDir, ["checkout", "main"]);
262+
263+
const result = yield* provider.writeArtifact({
264+
repoId: "repo-1",
265+
cwd: repoDir,
266+
path: "docs/product/overview.md",
267+
content: "# Overview\n\nProtected branch edit.\n",
268+
confirmed: true,
269+
});
270+
271+
assert.equal(result.status, "blocked");
272+
assert.equal(result.message.includes("protected branch main"), true);
273+
}),
274+
);
275+
});
276+
});

0 commit comments

Comments
 (0)