Skip to content

Commit ab819b8

Browse files
authored
feat(session): batch apply live comments for agent reviews (#179)
1 parent 6743d27 commit ab819b8

22 files changed

Lines changed: 1179 additions & 26 deletions

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Use it to:
164164
- optionally include raw patch text when an agent truly needs it
165165
- jump to a file, hunk, or line
166166
- reload the current window with a different `diff` or `show` command
167-
- add, list, and remove inline comments
167+
- add, batch-apply, list, and remove inline comments
168168

169169
Most users only need `hunk session ...`. Use `hunk mcp serve` only for manual startup or debugging of the local daemon.
170170

@@ -181,6 +181,8 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
181181
hunk session reload --repo . -- show HEAD~1 -- README.md
182182
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording"
183183
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" --focus
184+
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin
185+
printf '%s\n' '{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}' | hunk session comment apply --repo . --stdin --focus
184186
hunk session comment list --repo .
185187
hunk session comment rm --repo . <comment-id>
186188
hunk session comment clear --repo . --file README.md --yes
@@ -189,7 +191,9 @@ hunk session comment clear --repo . --file README.md --yes
189191
`hunk session review --json` returns file and hunk structure by default. Add `--include-patch` only when a caller truly needs raw unified diff text in the response.
190192

191193
`hunk session reload ... -- <hunk command>` swaps what a live session is showing without opening a new TUI window.
192-
Pass `--focus` to jump the live session to the new note.
194+
Pass `--focus` to jump the live session to the new note, or to the first note in a batch apply.
195+
196+
`hunk session comment apply` reads one stdin JSON object with a top-level `comments` array. Each item needs `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`.
193197

194198
- `--repo <path>` selects the live session by its current loaded repo root.
195199
- `--source <path>` is reload-only: it changes where the nested `diff` / `show` command runs, but does not select the session.

skills/hunk-review/SKILL.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ If no session exists, ask the user to launch Hunk in their terminal first.
1919
5. hunk session context --repo . # check current focus when needed
2020
6. hunk session navigate ... # move to the right place
2121
7. hunk session reload -- <command> # swap contents if needed
22-
8. hunk session comment add ... # leave review notes
22+
8. hunk session comment add ... # leave one review note
23+
9. hunk session comment apply ... # apply many agent notes in one stdin batch
2324
```
2425

2526
## Session selection
@@ -96,13 +97,17 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
9697

9798
```bash
9899
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus]
100+
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus]
99101
hunk session comment list --repo . [--file README.md]
100102
hunk session comment rm --repo . <comment-id>
101103
hunk session comment clear --repo . --yes [--file README.md]
102104
```
103105

106+
- `comment add` is best for one note; `comment apply` is best when an agent already has several notes ready
104107
- `comment add` requires `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`
105-
- Pass `--focus` when you want to jump to the new note
108+
- `comment apply` payload items require `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`
109+
- `comment apply` reads a JSON batch from stdin and validates the full batch before mutating the live session
110+
- Pass `--focus` when you want to jump to the new note or the first note in a batch
106111
- `comment list` and `comment clear` accept optional `--file`
107112
- Quote `--summary` and `--rationale` defensively in the shell
108113

@@ -125,13 +130,14 @@ Typical flow:
125130
1. Load the right content (`reload` if needed)
126131
2. Navigate to the first interesting file / hunk
127132
3. Add a comment explaining what's happening and why
128-
4. Move to the next point of interest -- repeat
133+
4. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations
129134
5. Summarize when done
130135

131136
Guidelines:
132137

133138
- Work in the order that tells the clearest story, not necessarily file order
134139
- Navigate before commenting so the user sees the code you're discussing
140+
- Use `comment apply` for agent-generated batches and `comment add` for one-off notes
135141
- Use `--focus` sparingly when the note itself should actively steer the review
136142
- Keep comments focused: intent, structure, risks, or follow-ups
137143
- Don't comment on every hunk -- highlight what the user wouldn't spot themselves
@@ -143,5 +149,6 @@ Guidelines:
143149
- **"Multiple active sessions match"** -- pass `<session-id>` explicitly.
144150
- **"No active Hunk session matches session path ..."** -- for advanced split-path reloads, verify the live window `Path` via `hunk session get` or `list`, then use `--session-path`.
145151
- **"Pass the replacement Hunk command after `--`"** -- include `--` before the nested `diff` / `show` command.
152+
- **"Pass --stdin to read batch comments from stdin JSON."** -- `comment apply` only reads its batch payload from stdin.
146153
- **"Specify exactly one navigation target"** -- pick one of `--hunk`, `--old-line`, or `--new-line`.
147154
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.

src/core/cli.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,94 @@ describe("parseCli", () => {
408408
});
409409
});
410410

411+
test("parses session comment apply with --focus", async () => {
412+
const originalStdin = Bun.stdin.stream;
413+
Bun.stdin.stream = () =>
414+
new ReadableStream({
415+
start(controller) {
416+
controller.enqueue(
417+
new TextEncoder().encode(
418+
'{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}',
419+
),
420+
);
421+
controller.close();
422+
},
423+
});
424+
425+
try {
426+
const parsed = await parseCli([
427+
"bun",
428+
"hunk",
429+
"session",
430+
"comment",
431+
"apply",
432+
"session-1",
433+
"--stdin",
434+
"--focus",
435+
"--json",
436+
]);
437+
438+
expect(parsed).toEqual({
439+
kind: "session",
440+
action: "comment-apply",
441+
selector: { sessionId: "session-1" },
442+
comments: [
443+
{
444+
filePath: "README.md",
445+
hunkNumber: 2,
446+
summary: "Explain this hunk",
447+
},
448+
],
449+
revealMode: "first",
450+
output: "json",
451+
});
452+
} finally {
453+
Bun.stdin.stream = originalStdin;
454+
}
455+
});
456+
457+
test("rejects session comment apply with an empty comments array", async () => {
458+
const originalStdin = Bun.stdin.stream;
459+
Bun.stdin.stream = () =>
460+
new ReadableStream({
461+
start(controller) {
462+
controller.enqueue(new TextEncoder().encode('{"comments":[]}'));
463+
controller.close();
464+
},
465+
});
466+
467+
try {
468+
await expect(
469+
parseCli(["bun", "hunk", "session", "comment", "apply", "session-1", "--stdin"]),
470+
).rejects.toThrow("Session comment apply expected at least one comment.");
471+
} finally {
472+
Bun.stdin.stream = originalStdin;
473+
}
474+
});
475+
476+
test("rejects session comment apply when both hunk aliases are present", async () => {
477+
const originalStdin = Bun.stdin.stream;
478+
Bun.stdin.stream = () =>
479+
new ReadableStream({
480+
start(controller) {
481+
controller.enqueue(
482+
new TextEncoder().encode(
483+
'{"comments":[{"filePath":"README.md","hunk":2,"hunkNumber":2,"summary":"Explain this hunk"}]}',
484+
),
485+
);
486+
controller.close();
487+
},
488+
});
489+
490+
try {
491+
await expect(
492+
parseCli(["bun", "hunk", "session", "comment", "apply", "session-1", "--stdin"]),
493+
).rejects.toThrow("Comment 1 must not specify both `hunk` and `hunkNumber`.");
494+
} finally {
495+
Bun.stdin.stream = originalStdin;
496+
}
497+
});
498+
411499
test("parses session comment list with file filter", async () => {
412500
const parsed = await parseCli([
413501
"bun",

src/core/cli.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
LayoutMode,
99
PagerCommandInput,
1010
ParsedCliInput,
11+
SessionCommentApplyItemInput,
1112
} from "./types";
1213
import { resolveCliVersion } from "./version";
1314

@@ -192,6 +193,97 @@ function resolveJsonOutput(options: { json?: boolean }) {
192193
return options.json ? "json" : "text";
193194
}
194195

196+
function parsePositiveJsonInt(
197+
value: unknown,
198+
{ field, itemNumber }: { field: string; itemNumber: number },
199+
) {
200+
if (value === undefined) {
201+
return undefined;
202+
}
203+
204+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
205+
throw new Error(`Comment ${itemNumber} field \`${field}\` must be a positive integer.`);
206+
}
207+
208+
return value;
209+
}
210+
211+
/** Parse one stdin JSON payload for `session comment apply`. */
212+
function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemInput[] {
213+
if (raw.trim().length === 0) {
214+
throw new Error("Session comment apply expected one JSON object on stdin.");
215+
}
216+
217+
let parsed: unknown;
218+
try {
219+
parsed = JSON.parse(raw);
220+
} catch {
221+
throw new Error("Session comment apply expected valid JSON on stdin.");
222+
}
223+
224+
if (!parsed || typeof parsed !== "object") {
225+
throw new Error("Session comment apply expected one JSON object with a comments array.");
226+
}
227+
228+
const value = parsed as Record<string, unknown>;
229+
if (!Array.isArray(value.comments)) {
230+
throw new Error("Session comment apply expected a top-level `comments` array.");
231+
}
232+
233+
if (value.comments.length === 0) {
234+
throw new Error("Session comment apply expected at least one comment.");
235+
}
236+
237+
return value.comments.map((comment, index) => {
238+
const itemNumber = index + 1;
239+
if (!comment || typeof comment !== "object") {
240+
throw new Error(`Comment ${itemNumber} must be a JSON object.`);
241+
}
242+
243+
const item = comment as Record<string, unknown>;
244+
const filePath = item.filePath;
245+
if (typeof filePath !== "string" || filePath.length === 0) {
246+
throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`);
247+
}
248+
249+
const summary = item.summary;
250+
if (typeof summary !== "string" || summary.length === 0) {
251+
throw new Error(`Comment ${itemNumber} requires a non-empty \`summary\`.`);
252+
}
253+
254+
const hunk = parsePositiveJsonInt(item.hunk, { field: "hunk", itemNumber });
255+
const hunkNumber = parsePositiveJsonInt(item.hunkNumber, { field: "hunkNumber", itemNumber });
256+
if (hunk !== undefined && hunkNumber !== undefined) {
257+
throw new Error(`Comment ${itemNumber} must not specify both \`hunk\` and \`hunkNumber\`.`);
258+
}
259+
260+
const oldLine = parsePositiveJsonInt(item.oldLine, { field: "oldLine", itemNumber });
261+
const newLine = parsePositiveJsonInt(item.newLine, { field: "newLine", itemNumber });
262+
const resolvedHunkNumber = hunk ?? hunkNumber;
263+
264+
const selectors = [
265+
resolvedHunkNumber !== undefined,
266+
oldLine !== undefined,
267+
newLine !== undefined,
268+
].filter(Boolean);
269+
if (selectors.length !== 1) {
270+
throw new Error(
271+
`Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`,
272+
);
273+
}
274+
275+
return {
276+
filePath,
277+
hunkNumber: resolvedHunkNumber,
278+
side: oldLine !== undefined ? "old" : newLine !== undefined ? "new" : undefined,
279+
line: oldLine ?? newLine,
280+
summary,
281+
rationale: typeof item.rationale === "string" ? item.rationale : undefined,
282+
author: typeof item.author === "string" ? item.author : undefined,
283+
};
284+
});
285+
}
286+
195287
/** Normalize one explicit session selector from either session id or repo root. */
196288
function resolveExplicitSessionSelector(
197289
sessionId: string | undefined,
@@ -487,6 +579,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
487579
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- diff [ref] [-- <pathspec...>]",
488580
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- show [ref] [-- <pathspec...>]",
489581
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text> [--focus]",
582+
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
490583
" hunk session comment list (<session-id> | --repo <path>)",
491584
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
492585
" hunk session comment clear (<session-id> | --repo <path>) --yes",
@@ -755,6 +848,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
755848
[
756849
"Usage:",
757850
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text> [--focus]",
851+
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
758852
" hunk session comment list (<session-id> | --repo <path>) [--file <path>]",
759853
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
760854
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --yes",
@@ -841,6 +935,80 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
841935
};
842936
}
843937

938+
if (commentSubcommand === "apply") {
939+
const command = new Command("session comment apply")
940+
.description("apply many live inline review notes from stdin JSON")
941+
.argument("[sessionId]")
942+
.option("--repo <path>", "target the live session whose repo root matches this path")
943+
.option("--stdin", "read the comment batch from stdin as JSON")
944+
.option("--focus", "apply the batch and focus the first note")
945+
.option("--json", "emit structured JSON");
946+
947+
let parsedSessionId: string | undefined;
948+
let parsedOptions: {
949+
repo?: string;
950+
stdin?: boolean;
951+
focus?: boolean;
952+
json?: boolean;
953+
} = {};
954+
955+
command.action(
956+
(
957+
sessionId: string | undefined,
958+
options: {
959+
repo?: string;
960+
stdin?: boolean;
961+
focus?: boolean;
962+
json?: boolean;
963+
},
964+
) => {
965+
parsedSessionId = sessionId;
966+
parsedOptions = options;
967+
},
968+
);
969+
970+
if (commentRest.includes("--help") || commentRest.includes("-h")) {
971+
return {
972+
kind: "help",
973+
text:
974+
`${command.helpInformation().trimEnd()}\n\n` +
975+
[
976+
"Stdin JSON shape:",
977+
" {",
978+
' "comments": [',
979+
" {",
980+
' "filePath": "README.md",',
981+
' "hunk": 2,',
982+
' "summary": "Explain this hunk",',
983+
' "rationale": "Optional detail",',
984+
' "author": "Pi"',
985+
" }",
986+
" ]",
987+
" }",
988+
].join("\n") +
989+
"\n",
990+
};
991+
}
992+
993+
await parseStandaloneCommand(command, commentRest);
994+
if (!parsedOptions.stdin) {
995+
throw new Error("Pass --stdin to read batch comments from stdin JSON.");
996+
}
997+
998+
const comments = parseSessionCommentApplyPayload(
999+
await new Response(Bun.stdin.stream()).text(),
1000+
);
1001+
1002+
return {
1003+
kind: "session",
1004+
action: "comment-apply",
1005+
output: resolveJsonOutput(parsedOptions),
1006+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
1007+
comments,
1008+
revealMode: parsedOptions.focus ? "first" : "none",
1009+
};
1010+
}
1011+
8441012
if (commentSubcommand === "list") {
8451013
const command = new Command("session comment list")
8461014
.description("list live inline review notes")
@@ -967,7 +1135,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
9671135
};
9681136
}
9691137

970-
throw new Error("Supported comment subcommands are add, list, rm, and clear.");
1138+
throw new Error("Supported comment subcommands are add, apply, list, rm, and clear.");
9711139
}
9721140

9731141
throw new Error(`Unknown session command: ${subcommand}`);

0 commit comments

Comments
 (0)