Skip to content

Commit 381b730

Browse files
authored
test: add local app route smoke
Adds explicit demo mode, a built-server signed-delivery app smoke, CI coverage for the route, and docs for showing the TypeScript webhook app working locally.
1 parent c4067c9 commit 381b730

7 files changed

Lines changed: 237 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ jobs:
3131

3232
- name: Build
3333
run: npm run build
34+
35+
- name: App route smoke
36+
run: node scripts/demo-app-smoke.mjs

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ reproduced, and changed through PRs instead of server-only edits.
2121
connects a GitHub App install to a familiar route.
2222
- `docs/coven-github-connection.md` - operator guide for connecting this
2323
TypeScript deployment bundle to the canonical `coven-github` app manifest.
24+
- `scripts/demo-app-smoke.mjs` - local signed-delivery demo for the example
25+
policy route.
2426
- `scripts/smoke-webhook.sh` - local HMAC signature smoke test for a running
2527
webhook endpoint.
2628

@@ -77,6 +79,7 @@ while a correctly HMAC-signed GitHub `ping` delivery is accepted without needing
7779
```bash
7880
npm test
7981
npm run build
82+
npm run smoke:app
8083
```
8184

8285
## Policy
@@ -98,6 +101,9 @@ connection guide in
98101
## Current behavior
99102

100103
- Emits headless contract v2 session briefs.
104+
- Supports explicit `COVEN_GITHUB_DEMO_MODE=1` local app smoke runs that verify
105+
signed delivery -> policy route -> delivery/task/result state without calling
106+
GitHub or `coven-code`.
101107
- Captures PR checkout metadata and changed-file patches before invoking
102108
`coven-code`.
103109
- Publishes visible structured review evidence, including `reviewed_files`,

docs/coven-github-connection.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ WEBHOOK_SECRET="$GITHUB_WEBHOOK_SECRET" \
9191
That proves the HTTP endpoint and HMAC signature path before any GitHub token or
9292
runtime work is attempted.
9393

94+
## Local App Demo
95+
96+
Before creating a real GitHub App, run the self-contained demo:
97+
98+
```bash
99+
npm run smoke:app
100+
```
101+
102+
The demo starts the built Node server on localhost, signs an `issues.labeled`
103+
payload with the same `sha256=` HMAC format GitHub uses, loads
104+
`config/example-policy.json`, and prints the resulting delivery, task, session
105+
brief, and result paths.
106+
107+
It runs with `COVEN_GITHUB_DEMO_MODE=1`. That mode is intentionally explicit:
108+
it verifies the app ingress and routing path, but does not mint GitHub
109+
installation tokens, clone repositories, run `coven-code`, or publish comments.
110+
94111
## Functional App Smoke
95112

96113
On a repository where the App is installed:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build": "tsc -p tsconfig.json",
1111
"start": "node dist/src/server.js",
1212
"dev": "tsx src/server.ts",
13+
"smoke:app": "npm run build && node scripts/demo-app-smoke.mjs",
1314
"test": "node --import tsx --test tests/*.test.ts"
1415
},
1516
"devDependencies": {

scripts/demo-app-smoke.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env node
2+
import {createHmac} from "node:crypto";
3+
import {mkdtempSync, readFileSync, writeFileSync} from "node:fs";
4+
import {tmpdir} from "node:os";
5+
import {join} from "node:path";
6+
7+
import {createConfig} from "../dist/src/adapter.js";
8+
import {createWebhookServer} from "../dist/src/server.js";
9+
10+
const root = new URL("..", import.meta.url).pathname;
11+
const stateDir = mkdtempSync(join(tmpdir(), "coven-github-demo-"));
12+
const policyPath = join(stateDir, "policy.json");
13+
const secret = "local-demo-webhook-secret";
14+
const deliveryId = "demo-delivery-issues-labeled";
15+
const port = Number.parseInt(process.env.PORT || "3137", 10);
16+
17+
writeFileSync(
18+
policyPath,
19+
readFileSync(new URL("../config/example-policy.json", import.meta.url)),
20+
);
21+
22+
const payload = {
23+
action: "labeled",
24+
installation: {id: 123456},
25+
repository: {
26+
id: 987654321,
27+
full_name: "OpenCoven/example",
28+
clone_url: "https://github.com/OpenCoven/example.git",
29+
default_branch: "main",
30+
},
31+
issue: {
32+
number: 42,
33+
title: "Wire the app",
34+
body: "Make the first app route functional.",
35+
labels: [{name: "coven:fix"}],
36+
},
37+
};
38+
const body = Buffer.from(JSON.stringify(payload));
39+
const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
40+
41+
const config = createConfig(
42+
{
43+
COVEN_GITHUB_DEMO_MODE: "1",
44+
COVEN_GITHUB_STATE_DIR: stateDir,
45+
COVEN_GITHUB_POLICY_PATH: policyPath,
46+
GITHUB_WEBHOOK_SECRET: secret,
47+
},
48+
root,
49+
);
50+
51+
const server = createWebhookServer(config);
52+
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
53+
54+
try {
55+
const response = await fetch(`http://127.0.0.1:${port}/webhook`, {
56+
method: "POST",
57+
headers: {
58+
"Content-Type": "application/json",
59+
"X-GitHub-Event": "issues",
60+
"X-GitHub-Delivery": deliveryId,
61+
"X-Hub-Signature-256": signature,
62+
},
63+
body,
64+
});
65+
const result = await response.json();
66+
const delivery = JSON.parse(readFileSync(join(stateDir, "deliveries", `${deliveryId}.json`), "utf8"));
67+
const task = JSON.parse(readFileSync(join(stateDir, "tasks", `${deliveryId}.json`), "utf8"));
68+
const demoResult = JSON.parse(readFileSync(task.result_path, "utf8"));
69+
70+
console.log(JSON.stringify({
71+
ok: response.ok,
72+
status: response.status,
73+
response: result,
74+
state_dir: stateDir,
75+
delivery: {
76+
id: delivery.delivery_id,
77+
event: delivery.event,
78+
routing_result: delivery.routing_result,
79+
state: delivery.state,
80+
repository: delivery.repository,
81+
},
82+
task: {
83+
id: task.task_id,
84+
state: task.state,
85+
trigger: task.trigger,
86+
familiar: task.familiar,
87+
issue_number: task.task?.issue_number,
88+
publication_state: task.publication_state,
89+
session_brief_path: task.session_brief_path,
90+
result_path: task.result_path,
91+
},
92+
result: {
93+
status: demoResult.status,
94+
summary: demoResult.summary,
95+
evidence_status: demoResult.review?.evidence_status,
96+
tests_run: demoResult.review?.tests_run,
97+
limitations: demoResult.review?.limitations,
98+
},
99+
}, null, 2));
100+
} finally {
101+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
102+
}

src/adapter.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface AdapterConfig {
2323
maxReviewFixLoops: number;
2424
codexTokensPath: string;
2525
maxWebhookBodyBytes: number;
26+
demoMode: boolean;
2627
}
2728

2829
export interface AdapterRequest {
@@ -78,6 +79,7 @@ export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = pro
7879
maxReviewFixLoops: envInt(env.COVEN_REVIEW_FIX_LOOPS, 0, 0, 5),
7980
codexTokensPath: configuredCodexTokensPath(env),
8081
maxWebhookBodyBytes: MAX_WEBHOOK_BODY_BYTES,
82+
demoMode: ["1", "true", "yes"].includes((env.COVEN_GITHUB_DEMO_MODE || "").trim().toLowerCase()),
8183
};
8284

8385
for (const directory of [config.deliveriesDir, config.tasksDir, config.workspacesDir, config.attemptsDir]) {
@@ -787,6 +789,10 @@ async function runTask(config: AdapterConfig, taskId: string): Promise<JsonObjec
787789
const workspace = join(config.workspacesDir, taskId, "repo");
788790

789791
try {
792+
if (config.demoMode) {
793+
return completeDemoTask(path, task, workspace, attemptDir);
794+
}
795+
790796
const token = await installationToken(config, task.installation_id);
791797
const askpass = writeAskpass(attemptDir);
792798
const env: NodeJS.ProcessEnv = {
@@ -885,6 +891,48 @@ async function runTask(config: AdapterConfig, taskId: string): Promise<JsonObjec
885891
}
886892
}
887893

894+
function completeDemoTask(path: string, task: JsonObject, workspace: string, attemptDir: string): JsonObject {
895+
mkdirSync(workspace, {recursive: true});
896+
const briefPath = join(attemptDir, "session-brief.json");
897+
const resultPath = join(attemptDir, "result.json");
898+
writeJsonAtomic(briefPath, sessionBrief(task, workspace, null));
899+
writeJsonAtomic(resultPath, {
900+
contract_version: "2",
901+
status: "success",
902+
summary: "Demo mode accepted a signed GitHub delivery, matched policy, and created a familiar task without external GitHub or coven-code calls.",
903+
files_changed: [],
904+
commits: [],
905+
review: {
906+
mode: "demo",
907+
evidence_status: "signed_delivery_policy_route",
908+
reviewed_files: [],
909+
supporting_files: [],
910+
findings: [],
911+
tests_run: [
912+
{
913+
command: "COVEN_GITHUB_DEMO_MODE=1 signed issues.labeled delivery",
914+
status: "passed",
915+
output_summary: "Webhook signature verified and example policy routed to familiar task.",
916+
},
917+
],
918+
limitations: [
919+
"Demo mode does not mint GitHub installation tokens, clone repositories, run coven-code, or publish GitHub comments.",
920+
],
921+
},
922+
});
923+
924+
task.state = "completed";
925+
task.demo_mode = true;
926+
task.runtime_exit_code = 0;
927+
task.session_brief_path = briefPath;
928+
task.session_brief_sha256 = fileSha256(briefPath);
929+
task.result_path = resultPath;
930+
task.publication_state = "demo_mode_no_github_calls";
931+
task.updated_at = utcNow();
932+
writeJsonAtomic(path, task);
933+
return task;
934+
}
935+
888936
function commandExists(command: string): boolean {
889937
return runCommand(["/bin/sh", "-lc", `command -v ${shellQuote(command)}`], undefined, undefined, 10).returncode === 0;
890938
}

tests/webhook-adapter.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { createHmac } from "node:crypto";
3-
import { mkdtempSync, readFileSync } from "node:fs";
3+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import test from "node:test";
@@ -337,3 +337,62 @@ test("example policy routes a labeled issue to the configured familiar", () => {
337337
skills: ["systematic-debugging", "test-driven-development"],
338338
});
339339
});
340+
341+
test("demo mode handles a signed labeled issue without external GitHub calls", async () => {
342+
const secret = "demo-route-secret";
343+
const stateDir = tempStateDir();
344+
const policyPath = join(stateDir, "policy.json");
345+
writeFileSync(
346+
policyPath,
347+
readFileSync(new URL("../config/example-policy.json", import.meta.url)),
348+
);
349+
const config = createConfig(
350+
{
351+
COVEN_GITHUB_DEMO_MODE: "1",
352+
COVEN_GITHUB_STATE_DIR: stateDir,
353+
COVEN_GITHUB_POLICY_PATH: policyPath,
354+
GITHUB_WEBHOOK_SECRET: secret,
355+
},
356+
process.cwd(),
357+
);
358+
const body = Buffer.from(JSON.stringify({
359+
action: "labeled",
360+
installation: {id: 123456},
361+
repository: {
362+
id: 987654321,
363+
full_name: "OpenCoven/example",
364+
clone_url: "https://github.com/OpenCoven/example.git",
365+
default_branch: "main",
366+
},
367+
issue: {
368+
number: 42,
369+
title: "Wire the app",
370+
body: "Make the first app route functional.",
371+
labels: [{name: "coven:fix"}],
372+
},
373+
}));
374+
375+
const response = await callWebhook(
376+
body,
377+
{
378+
"X-GitHub-Event": "issues",
379+
"X-GitHub-Delivery": "delivery-demo-mode",
380+
"X-Hub-Signature-256": signature(secret, body),
381+
},
382+
"auto",
383+
config,
384+
);
385+
386+
assert.equal(response.status, 200);
387+
assert.equal(response.body.action, "accepted");
388+
assert.equal(response.body.state, "completed");
389+
390+
const taskPath = join(stateDir, "tasks", "delivery-demo-mode.json");
391+
assert.equal(existsSync(taskPath), true);
392+
const task = JSON.parse(readFileSync(taskPath, "utf8")) as JsonObject;
393+
assert.equal(task.state, "completed");
394+
assert.equal(task.demo_mode, true);
395+
assert.equal(task.publication_state, "demo_mode_no_github_calls");
396+
assert.equal(existsSync(String(task.session_brief_path)), true);
397+
assert.equal(existsSync(String(task.result_path)), true);
398+
});

0 commit comments

Comments
 (0)