Compare commits
2 Commits
4e9e4cb9d3
...
662ba82d71
| Author | SHA1 | Date | |
|---|---|---|---|
| 662ba82d71 | |||
| b6d247dd4e |
88
.claude/spec/pr-review-comments.md
Normal file
88
.claude/spec/pr-review-comments.md
Normal file
@@ -0,0 +1,88 @@
|
||||
## Problem Statement
|
||||
|
||||
An agent doing a PR review round-trip with gitea-axi cannot complete it inside the tool.
|
||||
|
||||
When reading a reviewer's inline comments via `pr view --reviews`, the output gives the author, file path, and body, but not which line each comment is anchored to, nor the comment's id.
|
||||
Comments like "what does this do?" or "wasn't this set earlier?" are unanswerable from the output alone, forcing a fallback to a raw Gitea API call (and scraping the token out of tea's config) just to recover the anchor.
|
||||
|
||||
When writing, `pr review` accepts only a single `--body` for the whole review, so "reply to each of ten inline comments" collapses into one consolidated body that restates each thread by hand, rather than a reply landing under each comment where the reviewer left it.
|
||||
|
||||
## Solution
|
||||
|
||||
Complete the read and write halves of the inline-review round-trip, both at the existing `pr` commands.
|
||||
|
||||
On the read side, `pr view --reviews` surfaces each inline comment's `id`, its `diff_hunk` (so the anchoring code travels with the comment), and whether the thread is already `resolved`.
|
||||
|
||||
On the write side, `pr review` gains a `--comments-file` flag that carries a batch of inline comments — each either a reply into an existing thread (by comment id) or a fresh comment on a new-file line — mapped onto the review-submission payload Gitea already accepts.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a reviewing agent, I want each inline review comment's anchoring `diff_hunk` in the `--reviews` output, so that I can answer a bare "what does this do?" without a second API call.
|
||||
2. As a reviewing agent, I want each inline review comment's `id` in the `--reviews` output, so that I can target that exact comment when replying.
|
||||
3. As a reviewing agent, I want to see whether each inline comment's thread is already `resolved`, so that I skip settled conversations instead of re-answering them.
|
||||
4. As a reviewing agent, I want the `diff_hunk` trimmed to its header line plus its last couple of lines by default, so that I get the file-line anchor and the code at the comment without paying for the whole hunk.
|
||||
5. As a reviewing agent, I want `--full` to expand each `diff_hunk` to its complete text, so that I can read the entire hunk when the trimmed tail is not enough.
|
||||
6. As a PR author, I want to reply to an existing inline comment by its id, so that my reply lands in that reviewer's thread without me computing any line number or side.
|
||||
7. As a PR author, I want to post a fresh inline comment on a new-file line, so that I can raise a point on code no one has commented on yet.
|
||||
8. As a PR author, I want to submit a batch of inline comments in one file alongside my review, so that "reply to each of ten comments" is one command, not ten.
|
||||
9. As a PR author, I want gitea-axi to figure out the new-vs-old side of a reply for me, so that I never have to reason about diff sides.
|
||||
10. As a PR author, I want the inline-comment batch to compose with the existing review action and optional top-level body, so that I can approve/request-changes/comment while attaching inline replies.
|
||||
11. As a PR author, I want a clear validation error when a reply targets a comment id that isn't on the PR, so that a typo fails fast instead of silently posting nowhere.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Read side — anchor fields on `pr view --reviews`
|
||||
|
||||
- The `--reviews` review rows add three fields per inline comment: `id`, `diff_hunk`, and `resolved`.
|
||||
- `resolved` is `yes`/`no`, derived client-side from whether the comment's `resolver` is set (Gitea returns `resolver` as a populated user once a thread is resolved).
|
||||
- The raw `position` / `original_position` diff offsets are deliberately **not** surfaced — they are diff offsets an agent cannot map to a file line without the patch, and the `diff_hunk` header already carries the human-meaningful line range.
|
||||
- `diff_hunk` is rendered with a bespoke **structural trim**, not the char-based content-truncation used for bodies: by default, the hunk's `@@` header line plus its last two lines (collapsed when the hunk is three lines or fewer).
|
||||
This keeps both the file-line anchor (the `@@` header) and the code at the comment (the tail), which the keep-head char truncation would get backwards by dropping the tail.
|
||||
- Under `--full`, the entire `diff_hunk` is emitted verbatim, consistent with `--full` meaning "no trimming anywhere"; the hunk is never run through the body char-truncation path.
|
||||
- These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced.
|
||||
|
||||
### Write side — `--comments-file` on `pr review`
|
||||
|
||||
- `pr review` gains `--comments-file <path>`, a JSON array of inline-comment entries submitted as part of the review; the existing action flag (one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` stays optional.
|
||||
- Each array entry is one of two shapes, and there is **no `side` field anywhere**:
|
||||
- Reply: `{ "reply_to": <comment-id>, "body": "..." }`.
|
||||
- New comment: `{ "path": "...", "line": <new-file line>, "body": "..." }`.
|
||||
- A new comment maps `line` to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side.
|
||||
A prototype against the live host confirmed the create payload treats `new_position` as a **file line number** (not a diff offset) and that a single inline comment posts successfully this way.
|
||||
- A reply carries no line or side.
|
||||
gitea-axi locates the target comment (there is no get-comment-by-id endpoint, so it reuses the same reviews-plus-comments fan-out the read side already performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied.
|
||||
- All entries map onto the `comments[]` array of the review-submission payload (each element a `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added.
|
||||
- A `reply_to` id that is not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` already validates its action flags up front.
|
||||
- Mutation output follows the established action-block/entity-block convention for `pr review`; the inline-comment count is reflected in the reported result.
|
||||
|
||||
### Sequencing
|
||||
|
||||
- The read side ships first: it is pure read, and its surfaced `id` is the handle the write side's replies target.
|
||||
- The write side ships second, on top of the id exposed by the read side.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- Both halves are tested at the single existing **fixture-server CLI seam**: a fixture server maps request path/method to recorded Gitea JSON, the built CLI is driven via the run-CLI test helper, and assertions are on rendered `stdout` and on the recorded outbound requests.
|
||||
No new seam is introduced.
|
||||
- Good tests here assert external behavior only — the exact rendered TOON lines and the captured request payload — never internal rendering helpers.
|
||||
- Read side: drive `pr view N --reviews` (and again with `--full`) against fixture reviews and inline comments whose JSON carries `id`, `diff_hunk`, and a set/unset `resolver`; assert the rendered `id`, the trimmed-vs-full `diff_hunk`, and `resolved: yes/no`.
|
||||
Prior art: the existing `pr view --reviews` test that already stubs the reviews and per-review comments endpoints and asserts the `reviews[...]` block.
|
||||
- Write side: write a `--comments-file` via the existing temp-file test helper, drive `pr review N --comment --comments-file <f>` against a stubbed review-submission endpoint, and assert the **captured request body's** `comments[]` (path + `new_position`, and the reply case's reconstructed anchor) plus the action-block output.
|
||||
Prior art: the existing `pr review` test that inspects the recorded POST body via the fixture server's request log, and the reply case additionally stubs the reviews-plus-comments GETs used for the lookup.
|
||||
- The reply-lookup failure path is covered by asserting a `VALIDATION_ERROR` and that no submission request was made, matching the existing "rejects ... before any API call" tests.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Resolving / unresolving review conversations (issue #39).
|
||||
Gitea exposes no REST endpoint for this — verified exhaustively against the live host — and the only mechanism is a CSRF-guarded internal web route returning HTML, which a prototype confirmed rejects token auth.
|
||||
Parked as blocked-upstream; the read side's `resolved` field covers only *seeing* resolution state, not changing it.
|
||||
- A raw `api` passthrough / generic escape hatch (issue #40); dropped from this work.
|
||||
- Surfacing raw `position` / `original_position` diff offsets as rendered fields.
|
||||
- Inline repeatable comment flags (e.g. paired `--on path:line` / `--body`); the JSON `--comments-file` is the sole input shape.
|
||||
- Old-side *new* comments authored by hand; old-side anchoring is reachable only through the reply path, where it is inferred from the target comment.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- The prototype that validated the write-side `new_position` semantics left one non-removable residue on the live repo: a closed throwaway PR (#41) carrying one review comment, since Gitea cannot hard-delete PRs.
|
||||
- The "no REST resolve endpoint; web-route-only and CSRF-guarded" finding for the parked #39 is recorded so it is not re-derived.
|
||||
- Keeping gitea-js as the sole HTTP layer is preserved: dropping the passthrough and confining resolve to out-of-scope means no raw-request path is introduced by this work.
|
||||
40
.claude/tasks/0034-pr-review-anchor-fields.md
Normal file
40
.claude/tasks/0034-pr-review-anchor-fields.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
spec: pr-review-comments
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Extend `pr view <n> --reviews` so each inline review comment carries the anchoring information an agent needs to answer and reply to it without a second API call.
|
||||
Each inline-comment row gains three fields: `id` (the comment's own id, the handle replies target), `diff_hunk`, and `resolved` (`yes`/`no`, derived client-side from whether the comment's `resolver` user is populated).
|
||||
|
||||
`diff_hunk` renders with a bespoke **structural trim**, distinct from the char-based body truncation: by default the hunk's `@@` header line plus its last two lines, collapsed when the hunk is three lines or fewer.
|
||||
This keeps both the file-line anchor (the header) and the code at the comment (the tail).
|
||||
Under `--full` the entire `diff_hunk` is emitted verbatim — the hunk is never run through the body char-truncation path.
|
||||
|
||||
The raw `position` / `original_position` diff offsets are deliberately not surfaced.
|
||||
These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Each inline-comment row under `--reviews` renders `id`, `diff_hunk`, and `resolved`
|
||||
- [x] `resolved` is `yes` when the comment's `resolver` is populated and `no` otherwise
|
||||
- [x] Default `diff_hunk` shows the `@@` header line plus the hunk's last two lines; a hunk of three lines or fewer renders in full
|
||||
- [x] `--full` emits the entire `diff_hunk` verbatim, with no char-truncation applied to it
|
||||
- [x] Raw `position` / `original_position` are not rendered
|
||||
- [x] No additional API calls beyond the existing reviews-plus-per-review-comments fetch
|
||||
- [x] Fixture-server tests drive `pr view N --reviews` and `--full` against reviews and inline comments carrying `id`, `diff_hunk`, and a set/unset `resolver`, asserting the rendered `id`, trimmed-vs-full `diff_hunk`, and `resolved: yes/no`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The structural trim lives in `src/diff.ts` as a pure `trimDiffHunk(hunk)` beside `truncateDiff`: for a hunk longer than three lines it returns the first (`@@` header) line plus the last two lines joined by newline; three lines or fewer is returned unchanged.
|
||||
`buildReviewRows` in `src/commands/pr.ts` calls it for the default path and passes `diff_hunk` verbatim under `--full`, so the hunk never touches the char-based body-truncation path.
|
||||
- The inline-comment row's field order is `id, author, path, resolved, diff_hunk, body`, which is the TOON table header the tests assert against.
|
||||
- `resolved` is `comment.resolver ? "yes" : "no"` — a truthiness check on the SDK's optional `resolver` user, matching the spec's "set / not set" derivation.
|
||||
- No fresh RED for the `resolved: yes`, `--full`-verbatim, and ≤3-line-full cases: the cohesive `trimDiffHunk` helper (and the `resolver` truthiness branch) implemented in the first GREEN already covered them, so their tests were green on arrival.
|
||||
Each was proven non-vacuous with a sentinel-probe swap (per the TDD skill) and kept as a regression guard rather than dropped.
|
||||
- No new API calls: the three fields ride the existing reviews-plus-per-review-comments fan-out that `--reviews` already performs.
|
||||
|
||||
Review follow-ups (`/review-uncommitted`), both addressed in this branch:
|
||||
|
||||
- Standards flagged `id: comment.id ?? 0` as fabricating an identifier — the very handle a reply copies into `reply_to`. Replaced with a `reviewCommentId` helper that throws `UNKNOWN` on a missing id, mirroring the repo's existing `pullNumber`/`headSha` "never invent an identifier" convention.
|
||||
- Spec flagged the exactly-four-line trim boundary (the shortest hunk the `<= 3` guard actually trims) as untested. Added a regression test for it; a `<= 4` off-by-one would now fail.
|
||||
43
.claude/tasks/0035-pr-review-inline-comments.md
Normal file
43
.claude/tasks/0035-pr-review-inline-comments.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
spec: pr-review-comments
|
||||
blocked-by: 0034-pr-review-anchor-fields
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Give `pr review <n>` a `--comments-file <path>` flag carrying a JSON array of inline comments submitted as part of the review.
|
||||
The existing action flag (exactly one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` / `--body-file` stays optional, so an agent can approve/request-changes/comment while attaching inline replies.
|
||||
|
||||
Each array entry is one of two shapes, with no `side` field anywhere:
|
||||
|
||||
- New comment: `{ "path": "...", "line": <new-file line>, "body": "..." }` — `line` maps to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side.
|
||||
- Reply: `{ "reply_to": <comment-id>, "body": "..." }` — carries no line or side.
|
||||
gitea-axi locates the target comment (no get-comment-by-id endpoint exists, so it reuses the same reviews-plus-comments fan-out the read side performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied.
|
||||
|
||||
All entries map onto the `comments[]` array of the review-submission payload (each element `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added.
|
||||
A `reply_to` id not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` validates its action flags up front.
|
||||
Mutation output follows the established action-block/entity-block convention; the inline-comment count is reflected in the reported result.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `pr review N --comment --comments-file <f>` submits a review whose payload `comments[]` reflects the file's entries
|
||||
- [x] A new-comment entry maps `line` to `new_position` on the given `path`, always the new side
|
||||
- [x] A reply entry (`reply_to`) posts an inline comment whose anchor is reconstructed from the target comment's `diff_hunk`, with side inferred from the target (no `side` field consumed)
|
||||
- [x] The action flag remains required and top-level `--body` / `--body-file` still composes with the inline batch
|
||||
- [x] A `reply_to` id absent from the PR's review comments yields `VALIDATION_ERROR` (exit 2) before any submission request is made
|
||||
- [x] The mutation output reflects the inline-comment count in its result
|
||||
- [x] Fixture-server tests assert the captured submission body's `comments[]` for both the new-comment (`path` + `new_position`) and reply (reconstructed anchor) cases, the `VALIDATION_ERROR` no-request failure path, and the action-block output
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `--comments-file` parsing/validation and payload mapping live in a new module `src/review-comments.ts`. `loadInlineComments` reads and shape-validates the JSON batch up front (before any client is created); `resolveInlineComments` maps entries onto `CreatePullReviewComment[]`.
|
||||
- The reply anchor is reconstructed by `anchorFromDiffHunk` in `src/diff.ts` (beside `trimDiffHunk`): it walks the unified-diff hunk — whose last line is the commented line, by Gitea's convention — tracking old/new line numbers, and reads the last line off. An added/context last line anchors on `new_position`; a deleted last line on `old_position`. Both sides are covered by tests.
|
||||
- The reply-target lookup reuses a new `fetchAllReviewComments` in `src/review.ts` — the reviews-plus-comments fan-out the read side already performs — since Gitea has no get-comment-by-id endpoint. It only runs when a reply is present; a new-comment-only batch makes no extra GETs.
|
||||
- The submitted count rides the action block as `review: { number, action, comments: N }`, added only when a batch was submitted so a plain review's output is unchanged.
|
||||
- The action-flag-required criterion needed no new test: `resolveReviewAction` still runs first, so the existing zero/multiple-action tests cover it unchanged even with `--comments-file` present.
|
||||
|
||||
Review follow-ups (`/review-uncommitted`), all addressed in this branch:
|
||||
|
||||
- Standards flagged `readCommentsFile` as duplicating `body-source.ts`'s `readBodyFile`. Extracted the shared path-resolve-and-read into `src/flag-file.ts` (`readFlagFile`); both `--body-file` and `--comments-file` now go through it, keeping their flag-specific error messages.
|
||||
- Spec flagged that an entry mixing both shapes (`reply_to` + `path`/`line`) was silently resolved to a reply. `validateEntry` now rejects the contradictory mix as a `VALIDATION_ERROR` up front (with a regression test), making the two-shape contract exact.
|
||||
- Spec flagged the `target.path ?? ""` fallback as a silent mis-anchor. A reply target returned without `path`/`diff_hunk` now raises `UNKNOWN` instead of posting an empty path, mirroring the repo's other "never fabricate an anchor/identifier" guards.
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { axiError } from "./errors.js";
|
||||
import { readFlagFile } from "./flag-file.js";
|
||||
import { flagValue } from "./flags.js";
|
||||
|
||||
/**
|
||||
@@ -60,15 +59,5 @@ function bodyFlagSuggestion(command: string): string[] {
|
||||
}
|
||||
|
||||
function readBodyFile(deps: CliDeps, path: string, command: string): string {
|
||||
const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path);
|
||||
try {
|
||||
return readFileSync(absolute, "utf8");
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw axiError(
|
||||
`Cannot read --body-file ${path}: ${reason}`,
|
||||
"VALIDATION_ERROR",
|
||||
bodyFlagSuggestion(command),
|
||||
);
|
||||
}
|
||||
return readFlagFile(deps, path, "--body-file", bodyFlagSuggestion(command));
|
||||
}
|
||||
|
||||
@@ -38,12 +38,13 @@ import {
|
||||
splitFlag,
|
||||
} from "../flags.js";
|
||||
import { fetchChecks } from "../checks.js";
|
||||
import { fetchPullDiff, truncateDiff } from "../diff.js";
|
||||
import { fetchPullDiff, trimDiffHunk, truncateDiff } from "../diff.js";
|
||||
import { checkoutPullHead, currentBranch } from "../git.js";
|
||||
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||
import { fetchAllPages, readTotalCount } from "../paginate.js";
|
||||
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
|
||||
import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js";
|
||||
import { loadInlineComments, resolveInlineComments } from "../review-comments.js";
|
||||
import { suggestCommand } from "../suggestions.js";
|
||||
import { relativeTime } from "../time.js";
|
||||
|
||||
@@ -284,6 +285,8 @@ flags:
|
||||
--comment Leave a review comment without approving or rejecting
|
||||
--body <text> Review body
|
||||
--body-file <path> Read the review body from a file (mutually exclusive with --body)
|
||||
--comments-file <path> JSON array of inline comments to submit with the review;
|
||||
each entry is {reply_to, body} or {path, line, body}
|
||||
--help Show this help
|
||||
|
||||
global flags:
|
||||
@@ -739,6 +742,19 @@ function headSha(pull: PullRequest): string {
|
||||
return sha;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id Gitea gave a review comment — the handle a reply targets. The client
|
||||
* types it optional, but every real comment has one, and an id invented to fill
|
||||
* the gap would be reported as fact and copied into a `reply_to`, so a comment
|
||||
* without one is treated as the broken answer it is (mirroring {@link pullNumber}).
|
||||
*/
|
||||
function reviewCommentId(comment: PullReviewComment): number {
|
||||
if (comment.id === undefined) {
|
||||
throw axiError("Gitea returned a review comment with no id", "UNKNOWN");
|
||||
}
|
||||
return comment.id;
|
||||
}
|
||||
|
||||
interface PrDetailOptions {
|
||||
host: string;
|
||||
full: boolean;
|
||||
@@ -813,6 +829,13 @@ interface ReviewRowsOptions {
|
||||
* `official`/`stale` flags and its inline (diff) comments. One comments fetch per
|
||||
* review, all in flight at once; review and comment bodies truncate at 800 chars
|
||||
* unless `--full` is set.
|
||||
*
|
||||
* Each inline comment carries its anchor: `id` (the reply handle), `resolved`
|
||||
* (`yes`/`no` from whether Gitea populated the comment's `resolver`), and
|
||||
* `diff_hunk` — structurally trimmed to its `@@` header plus tail by default, or
|
||||
* emitted verbatim under `--full`. The raw `position`/`original_position` diff
|
||||
* offsets are deliberately not surfaced: they are unmappable to a file line
|
||||
* without the patch, and the `@@` header already carries the line range.
|
||||
*/
|
||||
async function buildReviewRows(
|
||||
api: GiteaClient,
|
||||
@@ -837,8 +860,11 @@ async function buildReviewRows(
|
||||
stale: review.stale ? "yes" : "no",
|
||||
body: truncate(review.body ?? ""),
|
||||
comments: commentLists[index]!.map((comment) => ({
|
||||
id: reviewCommentId(comment),
|
||||
author: comment.user?.login ?? "",
|
||||
path: comment.path ?? "",
|
||||
resolved: comment.resolver ? "yes" : "no",
|
||||
diff_hunk: options.full ? (comment.diff_hunk ?? "") : trimDiffHunk(comment.diff_hunk ?? ""),
|
||||
body: truncate(comment.body ?? ""),
|
||||
})),
|
||||
}));
|
||||
@@ -1200,15 +1226,18 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
|
||||
"--comment": { takesValue: false },
|
||||
"--body": { takesValue: true },
|
||||
"--body-file": { takesValue: true },
|
||||
"--comments-file": { takesValue: true },
|
||||
},
|
||||
"pr review",
|
||||
);
|
||||
const number = parsePositionalNumber(positionals, "pr review", "pull request");
|
||||
|
||||
// Everything the caller's own input can settle is checked before any request
|
||||
// goes out: the action flag count first, then the body source.
|
||||
// goes out: the action flag count first, then the body source, then the
|
||||
// inline-comment batch (parsed and shape-validated from the file).
|
||||
const chosen = resolveReviewAction(flags);
|
||||
const body = resolveBodySource(deps, flags, "pr review");
|
||||
const inlineComments = loadInlineComments(deps, flags, "pr review");
|
||||
|
||||
const context = await resolveRepoContext(deps);
|
||||
const api = createClient(context);
|
||||
@@ -1217,15 +1246,24 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
|
||||
if (body !== undefined) {
|
||||
payload.body = body;
|
||||
}
|
||||
// Replies are resolved against the PR's existing comments before the POST, so
|
||||
// an unknown `reply_to` fails without a submission ever going out.
|
||||
if (inlineComments !== undefined && inlineComments.length > 0) {
|
||||
payload.comments = await resolveInlineComments(api, context, number, inlineComments);
|
||||
}
|
||||
try {
|
||||
await api.repos.repoCreatePullReview(context.owner, context.name, number, payload);
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
|
||||
const item: Record<string, unknown> = { number, action: chosen.action };
|
||||
if (payload.comments !== undefined) {
|
||||
item.comments = payload.comments.length;
|
||||
}
|
||||
return renderDetail({
|
||||
noun: "review",
|
||||
item: { number, action: chosen.action },
|
||||
item,
|
||||
help: [suggestCommand(context, `pr view ${number} --reviews`, "to see the review in full")],
|
||||
});
|
||||
}
|
||||
|
||||
72
src/diff.ts
72
src/diff.ts
@@ -1,6 +1,6 @@
|
||||
import type { GiteaClient } from "./client.js";
|
||||
import type { RepoContext } from "./context.js";
|
||||
import { classifyHttpError } from "./errors.js";
|
||||
import { axiError, classifyHttpError } from "./errors.js";
|
||||
|
||||
/** The raw-diff truncation limit, distinct from the body/comment limits. */
|
||||
export const DIFF_TRUNCATE_LIMIT = 4000;
|
||||
@@ -56,3 +56,73 @@ export function truncateDiff(diff: string, full: boolean): DiffResult {
|
||||
original_length: diff.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally trim a review comment's `diff_hunk` to its anchor: the `@@`
|
||||
* header line plus the hunk's last two lines. A hunk of three lines or fewer is
|
||||
* already covered by that (header + last two), so it is returned unchanged.
|
||||
*
|
||||
* This is deliberately not the char-based body truncation: it keeps both the
|
||||
* file-line anchor (the `@@` header) and the code at the comment (the tail),
|
||||
* which a keep-head char truncation would get backwards by dropping the tail.
|
||||
*/
|
||||
export function trimDiffHunk(hunk: string): string {
|
||||
const lines = hunk.split("\n");
|
||||
if (lines.length <= 3) {
|
||||
return hunk;
|
||||
}
|
||||
return [lines[0], ...lines.slice(-2)].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-line anchor of a review comment, as the side-tagged position the
|
||||
* review-submission payload wants: exactly one of `new_position` (new side) or
|
||||
* `old_position` (old side) is set.
|
||||
*/
|
||||
export interface HunkAnchor {
|
||||
new_position?: number;
|
||||
old_position?: number;
|
||||
}
|
||||
|
||||
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
||||
|
||||
/**
|
||||
* Reconstruct the file-line anchor of the line a review comment's `diff_hunk`
|
||||
* belongs to. Gitea builds a comment's `diff_hunk` so it ends at the commented
|
||||
* line, so the anchor is that last body line: starting from the `@@ -old +new @@`
|
||||
* header, walk the hunk tracking old- and new-file line numbers, and read the
|
||||
* last line off. An added (`+`) or context (` `) line anchors on the new side
|
||||
* (`new_position`); a deleted (`-`) line anchors on the old side (`old_position`).
|
||||
*
|
||||
* This lets a reply post a matching inline comment that threads with its target
|
||||
* without the caller supplying any line or side — Gitea joins comments on the
|
||||
* same line into one conversation.
|
||||
*/
|
||||
export function anchorFromDiffHunk(hunk: string): HunkAnchor {
|
||||
const lines = hunk.split("\n");
|
||||
const header = HUNK_HEADER.exec(lines[0] ?? "");
|
||||
if (header === null) {
|
||||
throw axiError(
|
||||
"Gitea returned a review comment whose diff_hunk has no @@ header to anchor a reply",
|
||||
"UNKNOWN",
|
||||
);
|
||||
}
|
||||
let oldLine = Number(header[1]);
|
||||
let newLine = Number(header[2]);
|
||||
// An empty body is degenerate; default to the header's new-side start.
|
||||
let anchor: HunkAnchor = { new_position: newLine };
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.startsWith("-")) {
|
||||
anchor = { old_position: oldLine };
|
||||
oldLine += 1;
|
||||
} else if (line.startsWith("+")) {
|
||||
anchor = { new_position: newLine };
|
||||
newLine += 1;
|
||||
} else {
|
||||
anchor = { new_position: newLine };
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
}
|
||||
}
|
||||
return anchor;
|
||||
}
|
||||
|
||||
25
src/flag-file.ts
Normal file
25
src/flag-file.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { axiError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Read the file a path-valued flag points at, resolved against the caller's cwd.
|
||||
* A missing or unreadable file is a `VALIDATION_ERROR` naming the flag — the
|
||||
* shared reader behind `--body-file` and `--comments-file`. Parsing the contents
|
||||
* (as text, JSON, …) is the caller's job; this only turns a path into bytes.
|
||||
*/
|
||||
export function readFlagFile(
|
||||
deps: CliDeps,
|
||||
path: string,
|
||||
flag: string,
|
||||
suggestion: string[],
|
||||
): string {
|
||||
const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path);
|
||||
try {
|
||||
return readFileSync(absolute, "utf8");
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw axiError(`Cannot read ${flag} ${path}: ${reason}`, "VALIDATION_ERROR", suggestion);
|
||||
}
|
||||
}
|
||||
143
src/review-comments.ts
Normal file
143
src/review-comments.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { CreatePullReviewComment } from "gitea-js";
|
||||
import type { GiteaClient } from "./client.js";
|
||||
import type { RepoContext } from "./context.js";
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { anchorFromDiffHunk } from "./diff.js";
|
||||
import { axiError } from "./errors.js";
|
||||
import { readFlagFile } from "./flag-file.js";
|
||||
import { flagValue } from "./flags.js";
|
||||
import { fetchAllReviewComments } from "./review.js";
|
||||
|
||||
/**
|
||||
* One entry from a `pr review --comments-file` JSON array, in one of two shapes.
|
||||
* There is deliberately no `side` field: a new comment is always the new side,
|
||||
* and a reply's side is inferred from the comment it targets.
|
||||
*/
|
||||
export type InlineCommentEntry =
|
||||
| { reply_to: number; body: string }
|
||||
| { path: string; line: number; body: string };
|
||||
|
||||
const COMMENTS_FILE_SUGGESTION = [
|
||||
"Each entry must be `{ reply_to, body }` or `{ path, line, body }`",
|
||||
];
|
||||
|
||||
/**
|
||||
* Read and validate the `--comments-file` batch, if the flag is present. Returns
|
||||
* `undefined` when it is absent, so a plain review is unaffected. Every failure —
|
||||
* a missing/unreadable file, non-JSON, a non-array, or an entry matching neither
|
||||
* shape — is a `VALIDATION_ERROR` raised here, before any request goes out.
|
||||
*/
|
||||
export function loadInlineComments(
|
||||
deps: CliDeps,
|
||||
flags: Record<string, string | true>,
|
||||
command: string,
|
||||
): InlineCommentEntry[] | undefined {
|
||||
const path = flagValue(flags, "--comments-file");
|
||||
if (path === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return parseInlineComments(readCommentsFile(deps, path, command), command);
|
||||
}
|
||||
|
||||
function readCommentsFile(deps: CliDeps, path: string, command: string): string {
|
||||
return readFlagFile(deps, path, "--comments-file", [
|
||||
`Run \`gitea-axi ${command} --comments-file <path>\` with a readable JSON file`,
|
||||
]);
|
||||
}
|
||||
|
||||
function parseInlineComments(text: string, command: string): InlineCommentEntry[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw axiError(`--comments-file is not valid JSON: ${reason}`, "VALIDATION_ERROR");
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw axiError(
|
||||
"--comments-file must be a JSON array of inline-comment entries",
|
||||
"VALIDATION_ERROR",
|
||||
COMMENTS_FILE_SUGGESTION,
|
||||
);
|
||||
}
|
||||
return parsed.map((entry, index) => validateEntry(entry, index));
|
||||
}
|
||||
|
||||
function validateEntry(entry: unknown, index: number): InlineCommentEntry {
|
||||
const at = `--comments-file entry ${index}`;
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
throw axiError(`${at} must be an object`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
if (typeof record.body !== "string") {
|
||||
throw axiError(`${at} needs a string \`body\``, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
|
||||
}
|
||||
const isReply = record.reply_to !== undefined;
|
||||
const isNew = record.path !== undefined || record.line !== undefined;
|
||||
// The two shapes are exclusive: an entry carrying both a `reply_to` and a
|
||||
// `path`/`line` is contradictory (a reply needs neither), so it is rejected
|
||||
// rather than silently resolved to one arm.
|
||||
if (isReply && isNew) {
|
||||
throw axiError(
|
||||
`${at} mixes a reply (\`reply_to\`) with a new comment (\`path\`/\`line\`) — use one shape`,
|
||||
"VALIDATION_ERROR",
|
||||
COMMENTS_FILE_SUGGESTION,
|
||||
);
|
||||
}
|
||||
if (isReply) {
|
||||
if (typeof record.reply_to !== "number") {
|
||||
throw axiError(`${at} \`reply_to\` must be a number`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
|
||||
}
|
||||
return { reply_to: record.reply_to, body: record.body };
|
||||
}
|
||||
if (typeof record.path === "string" && typeof record.line === "number") {
|
||||
return { path: record.path, line: record.line, body: record.body };
|
||||
}
|
||||
throw axiError(
|
||||
`${at} must be a reply (\`reply_to\`) or a new comment (\`path\` + \`line\`)`,
|
||||
"VALIDATION_ERROR",
|
||||
COMMENTS_FILE_SUGGESTION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated inline-comment entries onto the review-submission payload's
|
||||
* `comments[]`. A new comment goes straight through — its new-file `line`
|
||||
* becomes `new_position` (always the new side). A reply is resolved against the
|
||||
* PR's existing review comments (one reviews-plus-comments fan-out, only when a
|
||||
* reply is present): its target is found by id, and that comment's anchor is
|
||||
* reconstructed from its own `diff_hunk` so the reply threads onto the same
|
||||
* line. A `reply_to` id absent from the PR is a `VALIDATION_ERROR`.
|
||||
*/
|
||||
export async function resolveInlineComments(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
number: number,
|
||||
entries: InlineCommentEntry[],
|
||||
): Promise<CreatePullReviewComment[]> {
|
||||
const hasReply = entries.some((entry) => "reply_to" in entry);
|
||||
const existing = hasReply ? await fetchAllReviewComments(api, context, number) : [];
|
||||
|
||||
return entries.map((entry) => {
|
||||
if ("reply_to" in entry) {
|
||||
const target = existing.find((comment) => comment.id === entry.reply_to);
|
||||
if (target === undefined) {
|
||||
throw axiError(
|
||||
`--comments-file reply_to ${entry.reply_to} is not a review comment on this pull request`,
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
}
|
||||
// The target's path and diff_hunk are what re-anchor the reply; a comment
|
||||
// returned without them is a broken answer, not a reply we can invent an
|
||||
// anchor for (mirroring the other "never fabricate" guards).
|
||||
if (target.path === undefined || target.diff_hunk === undefined) {
|
||||
throw axiError(
|
||||
`Gitea returned review comment ${entry.reply_to} without the path/diff_hunk needed to anchor a reply`,
|
||||
"UNKNOWN",
|
||||
);
|
||||
}
|
||||
return { path: target.path, ...anchorFromDiffHunk(target.diff_hunk), body: entry.body };
|
||||
}
|
||||
return { path: entry.path, new_position: entry.line, body: entry.body };
|
||||
});
|
||||
}
|
||||
@@ -99,3 +99,25 @@ export async function fetchReviewComments(
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every inline review comment on a PR, flattened across all its reviews. Gitea
|
||||
* has no get-comment-by-id endpoint, so the write side's reply path uses this
|
||||
* reviews-plus-comments fan-out — the same one `pr view --reviews` performs — to
|
||||
* locate a reply's target comment by id.
|
||||
*/
|
||||
export async function fetchAllReviewComments(
|
||||
api: GiteaClient,
|
||||
context: RepoContext,
|
||||
number: number,
|
||||
): Promise<PullReviewComment[]> {
|
||||
const reviews = await fetchReviews(api, context, number);
|
||||
const lists = await Promise.all(
|
||||
reviews.map((review) =>
|
||||
review.id !== undefined
|
||||
? fetchReviewComments(api, context, number, review.id)
|
||||
: Promise.resolve<PullReviewComment[]>([]),
|
||||
),
|
||||
);
|
||||
return lists.flat();
|
||||
}
|
||||
|
||||
@@ -110,6 +110,187 @@ describe("pr review", () => {
|
||||
expect(reviewPosted()?.body).toEqual({ event: "COMMENT", body: "Looks good" });
|
||||
});
|
||||
|
||||
it("maps a --comments-file new-comment entry to comments[] with new_position and reports the count", async () => {
|
||||
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ path: "src/x.ts", line: 42, body: "fresh point" }]),
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "review", "9", "--comment", "--comments-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(reviewPosted()?.body).toEqual({
|
||||
event: "COMMENT",
|
||||
comments: [{ path: "src/x.ts", new_position: 42, body: "fresh point" }],
|
||||
});
|
||||
expect(stdout).toContain("action: comment");
|
||||
expect(stdout).toContain("number: 9");
|
||||
expect(stdout).toContain("comments: 1");
|
||||
});
|
||||
|
||||
it("reconstructs a reply's anchor from the target comment's diff_hunk via the reviews fan-out", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: REVIEWS_PATH, body: {} },
|
||||
{
|
||||
method: "GET",
|
||||
path: REVIEWS_PATH,
|
||||
body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `${REVIEWS_PATH}/30/comments`,
|
||||
body: [
|
||||
{
|
||||
id: 500,
|
||||
path: "src/a.ts",
|
||||
diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added",
|
||||
body: "orig",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ reply_to: 500, body: "my reply" }]),
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "review", "9", "--comment", "--comments-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(reviewPosted()?.body).toEqual({
|
||||
event: "COMMENT",
|
||||
comments: [{ path: "src/a.ts", new_position: 12, body: "my reply" }],
|
||||
});
|
||||
expect(stdout).toContain("comments: 1");
|
||||
});
|
||||
|
||||
it("rejects a reply whose reply_to id is not among the PR's review comments without posting", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: REVIEWS_PATH, body: {} },
|
||||
{
|
||||
method: "GET",
|
||||
path: REVIEWS_PATH,
|
||||
body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `${REVIEWS_PATH}/30/comments`,
|
||||
body: [
|
||||
{
|
||||
id: 500,
|
||||
path: "src/a.ts",
|
||||
diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added",
|
||||
body: "orig",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ reply_to: 999, body: "reply to nobody" }]),
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "review", "9", "--comment", "--comments-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(reviewPosted()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("composes a top-level --body with the inline comments batch in one payload", async () => {
|
||||
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ path: "src/y.ts", line: 7, body: "inline note" }]),
|
||||
);
|
||||
|
||||
const { exitCode } = await runCliTest(
|
||||
[
|
||||
"pr",
|
||||
"review",
|
||||
"9",
|
||||
"--request-changes",
|
||||
"--body",
|
||||
"overall: please fix",
|
||||
"--comments-file",
|
||||
path,
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(reviewPosted()?.body).toEqual({
|
||||
event: "REQUEST_CHANGES",
|
||||
body: "overall: please fix",
|
||||
comments: [{ path: "src/y.ts", new_position: 7, body: "inline note" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors a reply on a deleted line to old_position inferred from the target", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "POST", path: REVIEWS_PATH, body: {} },
|
||||
{
|
||||
method: "GET",
|
||||
path: REVIEWS_PATH,
|
||||
body: [{ id: 40, state: "COMMENT", user: { login: "rev" } }],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `${REVIEWS_PATH}/40/comments`,
|
||||
body: [
|
||||
{
|
||||
id: 700,
|
||||
path: "src/b.ts",
|
||||
diff_hunk: "@@ -20,2 +20,1 @@\n ctx1\n-removed",
|
||||
body: "orig",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ reply_to: 700, body: "reply on deletion" }]),
|
||||
);
|
||||
|
||||
const { exitCode } = await runCliTest(
|
||||
["pr", "review", "9", "--comment", "--comments-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(reviewPosted()?.body).toEqual({
|
||||
event: "COMMENT",
|
||||
comments: [{ path: "src/b.ts", old_position: 21, body: "reply on deletion" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a comments-file entry mixing reply_to with path/line before any API call", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const path = files.write(
|
||||
"comments.json",
|
||||
JSON.stringify([{ reply_to: 500, path: "src/a.ts", line: 3, body: "confused entry" }]),
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "review", "9", "--comment", "--comments-file", path],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forwards a --body-file review body from the file contents", async () => {
|
||||
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
|
||||
const path = files.write("review.txt", "Please fix the tests");
|
||||
|
||||
@@ -169,7 +169,17 @@ describe("pr view", () => {
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews/11/comments",
|
||||
body: [{ user: { login: "alice" }, path: "src/x.ts", body: "nit here" }],
|
||||
body: [
|
||||
{
|
||||
id: 501,
|
||||
user: { login: "alice" },
|
||||
path: "src/x.ts",
|
||||
body: "nit here",
|
||||
diff_hunk: "@@ -10,6 +10,7 @@ func main\n a := 1\n b := 2\n c := 3\n d := 4\n+e := 5",
|
||||
position: 7,
|
||||
original_position: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
@@ -194,12 +204,253 @@ describe("pr view", () => {
|
||||
expect(stdout).toContain(" official: no");
|
||||
expect(stdout).toContain(" stale: no");
|
||||
expect(stdout).toContain(" stale: yes");
|
||||
expect(stdout).toContain(" comments[1]{author,path,body}:");
|
||||
expect(stdout).toContain(" alice,src/x.ts,nit here");
|
||||
expect(stdout).toContain(" comments[1]{id,author,path,resolved,diff_hunk,body}:");
|
||||
expect(stdout).toContain(
|
||||
' 501,alice,src/x.ts,no,"@@ -10,6 +10,7 @@ func main\\n d := 4\\n+e := 5",nit here',
|
||||
);
|
||||
expect(stdout).not.toContain("original_position");
|
||||
expect(stdout).not.toContain("position:");
|
||||
expect(stdout).not.toContain("review_count");
|
||||
expect(stdout).toContain("comment_count");
|
||||
});
|
||||
|
||||
it("renders resolved as yes when an inline comment's resolver is populated", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/8",
|
||||
body: {
|
||||
number: 8,
|
||||
title: "T",
|
||||
state: "open",
|
||||
user: { login: "alexion" },
|
||||
draft: false,
|
||||
merged: false,
|
||||
comments: 0,
|
||||
body: "b",
|
||||
head: { sha: "sha8", ref: "f" },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews",
|
||||
body: [
|
||||
{
|
||||
id: 21,
|
||||
state: "COMMENT",
|
||||
official: false,
|
||||
stale: false,
|
||||
dismissed: false,
|
||||
user: { login: "dave" },
|
||||
body: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews/21/comments",
|
||||
body: [
|
||||
{
|
||||
id: 777,
|
||||
user: { login: "dave" },
|
||||
path: "a.ts",
|
||||
resolver: { login: "carol" },
|
||||
diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d",
|
||||
body: "ok",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/commits/sha8/status",
|
||||
body: { sha: "sha8", total_count: 0, statuses: [] },
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "view", "8", "--reviews"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain('777,dave,a.ts,yes,"@@ -1,4 +1,5 @@\\n c\\n+d",ok');
|
||||
});
|
||||
|
||||
it("emits each inline comment's diff_hunk verbatim under --full, with no trimming", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/10",
|
||||
body: {
|
||||
number: 10,
|
||||
title: "T",
|
||||
state: "open",
|
||||
user: { login: "alexion" },
|
||||
draft: false,
|
||||
merged: false,
|
||||
comments: 0,
|
||||
body: "b",
|
||||
head: { sha: "sha10", ref: "f" },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews",
|
||||
body: [
|
||||
{
|
||||
id: 41,
|
||||
state: "COMMENT",
|
||||
official: false,
|
||||
stale: false,
|
||||
dismissed: false,
|
||||
user: { login: "frank" },
|
||||
body: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews/41/comments",
|
||||
body: [
|
||||
{
|
||||
id: 999,
|
||||
user: { login: "frank" },
|
||||
path: "big.ts",
|
||||
diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d",
|
||||
body: "hi",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/commits/sha10/status",
|
||||
body: { sha: "sha10", total_count: 0, statuses: [] },
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "view", "10", "--reviews", "--full"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain('999,frank,big.ts,no,"@@ -1,4 +1,5 @@\\n a\\n b\\n c\\n+d",hi');
|
||||
});
|
||||
|
||||
it("renders a diff_hunk of three lines or fewer in full by default, leaving short hunks untrimmed", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/9",
|
||||
body: {
|
||||
number: 9,
|
||||
title: "T",
|
||||
state: "open",
|
||||
user: { login: "alexion" },
|
||||
draft: false,
|
||||
merged: false,
|
||||
comments: 0,
|
||||
body: "b",
|
||||
head: { sha: "sha9", ref: "f" },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews",
|
||||
body: [
|
||||
{
|
||||
id: 31,
|
||||
state: "COMMENT",
|
||||
official: false,
|
||||
stale: false,
|
||||
dismissed: false,
|
||||
user: { login: "eve" },
|
||||
body: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews/31/comments",
|
||||
body: [
|
||||
{
|
||||
id: 888,
|
||||
user: { login: "eve" },
|
||||
path: "b.ts",
|
||||
diff_hunk: "@@ -5 +5 @@\n-x\n+y",
|
||||
body: "short",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/commits/sha9/status",
|
||||
body: { sha: "sha9", total_count: 0, statuses: [] },
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "view", "9", "--reviews"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain('888,eve,b.ts,no,"@@ -5 +5 @@\\n-x\\n+y",short');
|
||||
});
|
||||
|
||||
it("trims a 4-line diff_hunk to its header plus last two lines by default, dropping the one middle line", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/11",
|
||||
body: {
|
||||
number: 11,
|
||||
title: "T",
|
||||
state: "open",
|
||||
user: { login: "alexion" },
|
||||
draft: false,
|
||||
merged: false,
|
||||
comments: 0,
|
||||
body: "b",
|
||||
head: { sha: "sha11", ref: "f" },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews",
|
||||
body: [
|
||||
{
|
||||
id: 51,
|
||||
state: "COMMENT",
|
||||
official: false,
|
||||
stale: false,
|
||||
dismissed: false,
|
||||
user: { login: "grace" },
|
||||
body: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews/51/comments",
|
||||
body: [
|
||||
{
|
||||
id: 1010,
|
||||
user: { login: "grace" },
|
||||
path: "c.ts",
|
||||
diff_hunk: "@@ -2,3 +2,3 @@\n keep1\n drop_me\n+keep2",
|
||||
body: "boundary",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/repos/testowner/testrepo/commits/sha11/status",
|
||||
body: { sha: "sha11", total_count: 0, statuses: [] },
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "view", "11", "--reviews"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain('1010,grace,c.ts,no,"@@ -2,3 +2,3 @@\\n drop_me\\n+keep2",boundary');
|
||||
});
|
||||
|
||||
it("reports a nonexistent PR as PR_NOT_FOUND with exit 1", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user