Skip to content

Commit 3409d27

Browse files
committed
feat(cli): codemap pr-comment + action.yml integration (Slice 3 of #73 plan)
Slice 3 — markdown PR-summary writer for the cases SARIF→Code-Scanning doesn't cover well: private repos without GHAS, repos that haven't enabled Code Scanning, aggregate audit deltas without a single file:line anchor, and bot-context seeding (review bots read PR conversation, not workflow artifacts). Architecture (engine + CLI separation, mirrors show / impact / audit): - src/application/pr-comment-engine.ts — pure transport-agnostic renderer. Auto-detects input shape (audit envelope vs SARIF doc) + renders markdown grouped by delta key (audit) or rule id (SARIF). Lists >50 entries collapse to `… and N more`. Removed rows surface in their own collapsed section (audit only). - src/cli/cmd-pr-comment.ts — CLI wrapper. Reads JSON from a file or stdin (`-`). `--shape audit|sarif` overrides autodetection; `--json` emits structured envelope `{ markdown, findings_count, kind }` for action.yml steps. - src/cli/main.ts + src/cli/bootstrap.ts wire the new `pr-comment` verb (whitelist + dispatch). action.yml integration (Slice 2 stub replaced): - pr-comment toggle now actually invokes `codemap pr-comment` against the SARIF / JSON output file produced by the run step, then posts via `gh pr comment <PR> -F -`. Same binary that produced the output renders the comment — version stream stays coherent. Tests: - 12 new pr-comment-engine unit tests (input shape detection, no-drift / no-findings ✅ rendering, audit summary line + per-delta sections, SARIF rule grouping, location formatting, >50 collapse). - Smoke verified: real audit envelope produces markdown with file: links + delta sections; SARIF doc groups findings by rule id. Lockstep updates (per docs/README.md Rule 10): - .agents/rules/codemap.md + templates/agents/rules/codemap.md gain rows for `--ci` aggregate flag (Slice 1b) and `pr-comment` renderer (Slice 3).
1 parent 9383a95 commit 3409d27

8 files changed

Lines changed: 767 additions & 5 deletions

File tree

.agents/rules/codemap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ A local database (default **`.codemap/index.db`**) indexes structure: symbols, i
3737
| Impact (blast-radius walker) || `bun src/index.ts impact <target> [--direction up\|down\|both] [--depth N] [--via <b>] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries |
3838
| Coverage ingest || `bun src/index.ts ingest-coverage <path> [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. |
3939
| SARIF / GH annotations || `bun src/index.ts query --recipe deprecated-symbols --format sarif` · `… --format annotations` |
40+
| `--ci` aggregate flag || `bun src/index.ts query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses no-findings stderr warning. Mutually exclusive with `--json` / `--format <other>`. |
41+
| PR-comment renderer || `bun src/index.ts pr-comment <input.json>` (or `-` for stdin) — renders an audit JSON envelope or SARIF doc as a markdown PR-summary comment. Pipe to `gh pr comment <PR> -F -`. Useful for private repos without GHAS, aggregate audit deltas, or bot-context seeding. |
4042
| Mermaid graph (≤50 edges) || `bun src/index.ts query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. |
4143
| Diff preview || `bun src/index.ts query --format diff '<SQL returning file_path, line_start, before_pattern, after_pattern>'` — read-only unified diff; `--format diff-json` returns structured hunks for agents. |
4244
| FTS5 full-text (opt-in) | `--with-fts` | `bun src/index.ts --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. |

action.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,22 @@ runs:
244244
- name: Post PR summary comment
245245
if: steps.gate.outputs.skip != 'true' && inputs.pr-comment == 'true' && github.event_name == 'pull_request' && always()
246246
shell: bash
247+
working-directory: ${{ inputs.working-directory }}
247248
env:
248249
EXEC: ${{ steps.detect-pm.outputs.exec }}
249-
OUTPUT_PATH: ${{ inputs.working-directory }}/${{ inputs.output-path }}
250-
FORMAT: ${{ inputs.format }}
250+
OUTPUT_PATH: ${{ inputs.output-path }}
251251
PR_NUMBER: ${{ github.event.pull_request.number }}
252252
GH_TOKEN: ${{ inputs.token }}
253253
run: |
254-
# Slice 3 lands `codemap pr-comment` later; until then, just stub a
255-
# short marker comment so downstream consumers can see the toggle works.
256-
echo "::warning::codemap action: pr-comment writer (Slice 3) not yet implemented; toggle was set but no comment was posted."
254+
# Render the markdown body via `codemap pr-comment`, then post via
255+
# `gh pr comment`. The same binary that produced the SARIF / JSON
256+
# output renders the comment — keeps the version stream coherent.
257+
BODY=$($EXEC pr-comment "$OUTPUT_PATH" 2>/dev/null) || {
258+
echo "::warning::codemap action: pr-comment renderer failed; skipping PR comment."
259+
exit 0
260+
}
261+
if [ -z "$BODY" ]; then
262+
echo "::notice::codemap action: pr-comment produced empty body; skipping."
263+
exit 0
264+
fi
265+
echo "$BODY" | gh pr comment "$PR_NUMBER" -F -
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
detectCommentInputShape,
5+
renderAuditComment,
6+
renderSarifComment,
7+
} from "./pr-comment-engine";
8+
9+
describe("detectCommentInputShape", () => {
10+
it("identifies audit envelopes by the deltas field", () => {
11+
expect(detectCommentInputShape({ head: {}, deltas: {} })).toBe("audit");
12+
});
13+
14+
it("identifies SARIF docs by the runs[] field", () => {
15+
expect(detectCommentInputShape({ version: "2.1.0", runs: [] })).toBe(
16+
"sarif",
17+
);
18+
});
19+
20+
it("returns 'empty' for {}", () => {
21+
expect(detectCommentInputShape({})).toBe("empty");
22+
});
23+
24+
it("returns 'unknown' for arbitrary objects", () => {
25+
expect(detectCommentInputShape({ something: "else" })).toBe("unknown");
26+
});
27+
28+
it("returns 'unknown' for non-objects", () => {
29+
expect(detectCommentInputShape("hello")).toBe("unknown");
30+
expect(detectCommentInputShape(null)).toBe("unknown");
31+
expect(detectCommentInputShape(42)).toBe("unknown");
32+
});
33+
});
34+
35+
describe("renderAuditComment", () => {
36+
it("emits ✅ when no drift across deltas", () => {
37+
const r = renderAuditComment({
38+
head: {},
39+
deltas: {
40+
files: { base: { source: "ref", ref: "main" }, added: [], removed: [] },
41+
},
42+
});
43+
expect(r.findings_count).toBe(0);
44+
expect(r.kind).toBe("audit");
45+
expect(r.markdown).toContain("✅");
46+
expect(r.markdown).toContain("No structural drift");
47+
});
48+
49+
it("renders summary line + per-delta sections for added rows", () => {
50+
const r = renderAuditComment({
51+
head: { sha: "abc12345" },
52+
deltas: {
53+
files: {
54+
base: { source: "ref", ref: "origin/main", sha: "deadbeef0000" },
55+
added: [{ path: "src/new.ts" }],
56+
removed: [],
57+
},
58+
dependencies: {
59+
base: { source: "baseline", name: "base-dependencies" },
60+
added: [
61+
{ from_path: "src/a.ts", to_path: "src/b.ts" },
62+
{ from_path: "src/c.ts", to_path: "src/d.ts" },
63+
],
64+
removed: [],
65+
},
66+
},
67+
});
68+
expect(r.findings_count).toBe(3);
69+
// Summary line surfaces non-zero deltas.
70+
expect(r.markdown).toContain("**files**: +1 / -0");
71+
expect(r.markdown).toContain("**dependencies**: +2 / -0");
72+
// Sections per delta.
73+
expect(r.markdown).toContain("### files");
74+
expect(r.markdown).toContain("### dependencies");
75+
// File-added row → location-only formatting.
76+
expect(r.markdown).toContain("`src/new.ts`");
77+
// Dependency-added row → from → to formatting.
78+
expect(r.markdown).toContain("`src/a.ts` → `src/b.ts`");
79+
// Baseline metadata visible.
80+
expect(r.markdown).toContain("origin/main");
81+
expect(r.markdown).toContain("base-dependencies");
82+
});
83+
84+
it("collapses lists >50 rows", () => {
85+
const added: Record<string, unknown>[] = [];
86+
for (let i = 0; i < 75; i++) added.push({ path: `src/f${i}.ts` });
87+
const r = renderAuditComment({
88+
head: {},
89+
deltas: {
90+
files: {
91+
base: { source: "ref", ref: "main", sha: "abc" },
92+
added,
93+
removed: [],
94+
},
95+
},
96+
});
97+
expect(r.findings_count).toBe(75);
98+
expect(r.markdown).toContain("… and 25 more");
99+
});
100+
101+
it("includes removed rows in their own collapsed section", () => {
102+
const r = renderAuditComment({
103+
head: {},
104+
deltas: {
105+
deprecated: {
106+
base: { source: "ref", ref: "main", sha: "abc" },
107+
added: [],
108+
removed: [{ name: "oldFn", kind: "function", file_path: "src/x.ts" }],
109+
},
110+
},
111+
});
112+
expect(r.markdown).toContain("➖ 1 removed");
113+
expect(r.markdown).toContain("`oldFn`");
114+
});
115+
});
116+
117+
describe("renderSarifComment", () => {
118+
it("emits ✅ when no findings", () => {
119+
const r = renderSarifComment({
120+
version: "2.1.0",
121+
runs: [
122+
{
123+
tool: { driver: { name: "codemap", rules: [] } },
124+
results: [],
125+
},
126+
],
127+
});
128+
expect(r.findings_count).toBe(0);
129+
expect(r.markdown).toContain("✅");
130+
});
131+
132+
it("groups results by ruleId in summary + sections", () => {
133+
const r = renderSarifComment({
134+
version: "2.1.0",
135+
runs: [
136+
{
137+
tool: {
138+
driver: {
139+
name: "codemap",
140+
rules: [
141+
{
142+
id: "codemap.deprecated-symbols",
143+
name: "deprecated-symbols",
144+
},
145+
{ id: "codemap.untested-and-dead", name: "untested-and-dead" },
146+
],
147+
},
148+
},
149+
results: [
150+
{
151+
ruleId: "codemap.deprecated-symbols",
152+
message: { text: "oldFn is deprecated" },
153+
locations: [
154+
{
155+
physicalLocation: {
156+
artifactLocation: { uri: "src/a.ts" },
157+
region: { startLine: 12 },
158+
},
159+
},
160+
],
161+
},
162+
{
163+
ruleId: "codemap.deprecated-symbols",
164+
message: { text: "anotherFn is deprecated" },
165+
},
166+
{
167+
ruleId: "codemap.untested-and-dead",
168+
message: { text: "deadFn never called" },
169+
locations: [
170+
{
171+
physicalLocation: {
172+
artifactLocation: { uri: "src/b.ts" },
173+
},
174+
},
175+
],
176+
},
177+
],
178+
},
179+
],
180+
});
181+
expect(r.findings_count).toBe(3);
182+
// Summary line.
183+
expect(r.markdown).toContain("**codemap.deprecated-symbols**: 2");
184+
expect(r.markdown).toContain("**codemap.untested-and-dead**: 1");
185+
// Per-rule sections.
186+
expect(r.markdown).toContain("### codemap.deprecated-symbols (2)");
187+
expect(r.markdown).toContain("### codemap.untested-and-dead (1)");
188+
// Result lines with location.
189+
expect(r.markdown).toContain("`src/a.ts:12`");
190+
expect(r.markdown).toContain("`src/b.ts`");
191+
// Result without location still renders the message.
192+
expect(r.markdown).toContain("anotherFn is deprecated");
193+
});
194+
195+
it("collapses results lists >50 entries per rule", () => {
196+
const results = [];
197+
for (let i = 0; i < 75; i++) {
198+
results.push({
199+
ruleId: "codemap.bulk",
200+
message: { text: `finding ${i}` },
201+
});
202+
}
203+
const r = renderSarifComment({
204+
version: "2.1.0",
205+
runs: [
206+
{
207+
tool: {
208+
driver: {
209+
name: "codemap",
210+
rules: [{ id: "codemap.bulk", name: "bulk" }],
211+
},
212+
},
213+
results,
214+
},
215+
],
216+
});
217+
expect(r.findings_count).toBe(75);
218+
expect(r.markdown).toContain("… and 25 more");
219+
});
220+
});

0 commit comments

Comments
 (0)