hive-forge: post inline review comments with pr reviews --at, number diff lines with pr diff --numbered
This commit is contained in:
parent
c85233efd4
commit
eda7120f08
2 changed files with 224 additions and 9 deletions
|
|
@ -13,6 +13,8 @@
|
|||
//! lockfile without any information the reader can act on.
|
||||
//! Pass `--full` to dump the unfiltered diff.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery;
|
||||
|
|
@ -29,6 +31,11 @@ pub struct Args {
|
|||
/// placeholder so human-authored changes stay readable.
|
||||
#[arg(long)]
|
||||
full: bool,
|
||||
/// Prefix every added or unchanged line with its new-file line number,
|
||||
/// the number `pr reviews --at path:LINE` takes. Removed lines get no
|
||||
/// number: an inline comment sits on the new file.
|
||||
#[arg(long)]
|
||||
numbered: bool,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
|
|
@ -49,10 +56,57 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
} else {
|
||||
collapse_autogenerated(&diff)
|
||||
};
|
||||
print!("{out}");
|
||||
if args.numbered {
|
||||
print!("{}", number_new_lines(&out));
|
||||
} else {
|
||||
print!("{out}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Width of the line-number column `number_new_lines` adds.
|
||||
const NUMBER_WIDTH: usize = 6;
|
||||
|
||||
/// Prefix each line of every hunk with its new-file line number (added and
|
||||
/// unchanged lines) or blanks (removed lines and `\ No newline` markers), so a
|
||||
/// reviewer can read the `LINE` of `pr reviews --at path:LINE` off the diff.
|
||||
/// Everything outside a hunk (git headers, collapsed placeholders) is passed
|
||||
/// through unchanged.
|
||||
fn number_new_lines(diff: &str) -> String {
|
||||
let blank = " ".repeat(NUMBER_WIDTH);
|
||||
let mut out = String::with_capacity(diff.len() + diff.len() / 8);
|
||||
let mut next: Option<u64> = None;
|
||||
for line in diff.lines() {
|
||||
if let Some(rest) = line.strip_prefix("@@ ") {
|
||||
next = hunk_new_start(rest);
|
||||
out.push_str(line);
|
||||
} else if line.starts_with("diff --git ") {
|
||||
next = None;
|
||||
out.push_str(line);
|
||||
} else if let Some(n) = next {
|
||||
if line.starts_with('-') || line.starts_with('\\') {
|
||||
out.push_str(&blank);
|
||||
} else {
|
||||
let _ = write!(out, "{n:>NUMBER_WIDTH$}");
|
||||
next = Some(n + 1);
|
||||
}
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The new-file start line of a hunk header, from `-a,b +c,d @@ ...` (the
|
||||
/// text after the leading `@@ `): `c`.
|
||||
fn hunk_new_start(header: &str) -> Option<u64> {
|
||||
let new = header.split_whitespace().find(|w| w.starts_with('+'))?;
|
||||
new.trim_start_matches('+').split(',').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Walk a unified diff line-by-line. When a `diff --git` line
|
||||
/// names a known autogenerated file (`is_autogenerated`), suppress
|
||||
/// every line of that per-file section (the git headers AND the
|
||||
|
|
@ -224,6 +278,58 @@ fn is_autogenerated(path: &str) -> bool {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TWO_FILES: &str = "\
|
||||
diff --git a/a.rs b/a.rs
|
||||
index 1..2 100644
|
||||
--- a/a.rs
|
||||
+++ b/a.rs
|
||||
@@ -10,4 +20,5 @@ fn f() {
|
||||
keep
|
||||
-gone
|
||||
+new one
|
||||
+new two
|
||||
tail
|
||||
\\ No newline at end of file
|
||||
diff --git a/b.rs b/b.rs
|
||||
--- a/b.rs
|
||||
+++ b/b.rs
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn new_file_lines_are_numbered_from_the_hunk_header() {
|
||||
let got = number_new_lines(TWO_FILES);
|
||||
let expected = "\
|
||||
diff --git a/a.rs b/a.rs
|
||||
index 1..2 100644
|
||||
--- a/a.rs
|
||||
+++ b/a.rs
|
||||
@@ -10,4 +20,5 @@ fn f() {
|
||||
20 keep
|
||||
-gone
|
||||
21 +new one
|
||||
22 +new two
|
||||
23 tail
|
||||
\\ No newline at end of file
|
||||
diff --git a/b.rs b/b.rs
|
||||
--- a/b.rs
|
||||
+++ b/b.rs
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
1 +y
|
||||
";
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hunk_header_without_a_comma_still_gives_its_start() {
|
||||
assert_eq!(hunk_new_start("-1 +7 @@"), Some(7));
|
||||
assert_eq!(hunk_new_start("-3,2 +9,4 @@ ctx"), Some(9));
|
||||
assert_eq!(hunk_new_start("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_known_lockfiles() {
|
||||
assert!(is_autogenerated("flake.lock"));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! `pr reviews <number>` — list reviews, or submit one via `--approve` /
|
||||
//! `--request-changes` / `--comment`.
|
||||
//! `--request-changes` / `--comment`, optionally with inline `--at` comments.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clap::Args as ClapArgs;
|
||||
use forgejo_api::structs::CreatePullReviewOptions;
|
||||
use forgejo_api::structs::{CreatePullReviewComment, CreatePullReviewOptions};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::{Client, index};
|
||||
|
|
@ -33,6 +33,19 @@ pub struct Args {
|
|||
#[arg(long, short = 'm')]
|
||||
body: Option<String>,
|
||||
|
||||
/// An inline comment on a line of the new file: `--at path:LINE "text"`.
|
||||
/// Repeat it to put several comments in the same review. LINE is the
|
||||
/// new-file line number, which `pr diff --numbered` prints. Needs
|
||||
/// --approve, --request-changes or --comment.
|
||||
#[arg(
|
||||
long,
|
||||
num_args = 2,
|
||||
value_names = ["PATH:LINE", "TEXT"],
|
||||
action = clap::ArgAction::Append,
|
||||
conflicts_with = "only",
|
||||
)]
|
||||
at: Vec<String>,
|
||||
|
||||
/// When listing: narrow the inline comments shown (and leave out the
|
||||
/// reviews with none left). `unresolved` keeps every comment nobody has
|
||||
/// resolved yet: what a re-review has to check.
|
||||
|
|
@ -61,22 +74,65 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
};
|
||||
|
||||
if let Some(ev) = event {
|
||||
submit_review(client, args.number, ev, args.body)
|
||||
let comments = parse_inline_comments(&args.at)?;
|
||||
submit_review(client, args.number, ev, args.body, comments)
|
||||
} else {
|
||||
if args.body.is_some() {
|
||||
bail!("--body requires one of --approve / --request-changes / --comment");
|
||||
}
|
||||
if !args.at.is_empty() {
|
||||
bail!("--at requires one of --approve / --request-changes / --comment");
|
||||
}
|
||||
list_reviews(client, args.number, args.only)
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`) and print
|
||||
/// a compact summary of the created review.
|
||||
fn submit_review(client: &Client, number: u64, event: &str, body: Option<String>) -> Result<()> {
|
||||
/// Turn the `--at` values (flat `PATH:LINE`, `TEXT` pairs) into the inline
|
||||
/// comments of a review. Lines are new-file lines.
|
||||
fn parse_inline_comments(at: &[String]) -> Result<Vec<CreatePullReviewComment>> {
|
||||
at.chunks(2)
|
||||
.map(|pair| {
|
||||
let [location, text] = pair else {
|
||||
bail!("--at takes a PATH:LINE and a TEXT");
|
||||
};
|
||||
let (path, line) = location
|
||||
.rsplit_once(':')
|
||||
.with_context(|| format!("--at {location:?}: expected PATH:LINE"))?;
|
||||
let line: i64 =
|
||||
line.parse().ok().filter(|l| *l > 0).with_context(|| {
|
||||
format!("--at {location:?}: LINE must be a positive number")
|
||||
})?;
|
||||
if path.is_empty() {
|
||||
bail!("--at {location:?}: PATH is empty");
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
bail!("--at {location:?}: the comment text is empty");
|
||||
}
|
||||
Ok(CreatePullReviewComment {
|
||||
body: Some(text.clone()),
|
||||
extra_lines_count: None,
|
||||
new_position: Some(line),
|
||||
old_position: Some(0),
|
||||
path: Some(path.to_owned()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`), with
|
||||
/// its inline `comments`, and print a compact summary of the created review.
|
||||
fn submit_review(
|
||||
client: &Client,
|
||||
number: u64,
|
||||
event: &str,
|
||||
body: Option<String>,
|
||||
comments: Vec<CreatePullReviewComment>,
|
||||
) -> Result<()> {
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
let n_comments = comments.len();
|
||||
let payload = CreatePullReviewOptions {
|
||||
body: Some(body.unwrap_or_default()),
|
||||
comments: None,
|
||||
comments: (!comments.is_empty()).then_some(comments),
|
||||
commit_id: None,
|
||||
event: Some(event.to_owned()),
|
||||
};
|
||||
|
|
@ -88,6 +144,7 @@ fn submit_review(client: &Client, number: u64, event: &str, body: Option<String>
|
|||
"id": review.id,
|
||||
"state": review.state,
|
||||
"user": review.user.as_ref().and_then(|u| u.login.as_deref()),
|
||||
"inline_comments": n_comments,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +391,54 @@ fn list_reviews_text(client: &Client, number: u64, reviews: &[Value], only: Opti
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(pairs: &[(&str, &str)]) -> Vec<String> {
|
||||
pairs
|
||||
.iter()
|
||||
.flat_map(|(loc, text)| [(*loc).to_owned(), (*text).to_owned()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_pairs_become_new_file_line_comments() {
|
||||
let got = parse_inline_comments(&at(&[
|
||||
("src/a.rs:12", "off by one"),
|
||||
("docs/x:y.md:3", "colon in the path"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].path.as_deref(), Some("src/a.rs"));
|
||||
assert_eq!(got[0].new_position, Some(12));
|
||||
assert_eq!(got[0].old_position, Some(0));
|
||||
assert_eq!(got[0].body.as_deref(), Some("off by one"));
|
||||
assert_eq!(
|
||||
got[1].path.as_deref(),
|
||||
Some("docs/x:y.md"),
|
||||
"the last colon splits"
|
||||
);
|
||||
assert_eq!(got[1].new_position, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_at_is_refused_with_the_reason() {
|
||||
for (pairs, needle) in [
|
||||
(at(&[("src/a.rs", "no line")]), "expected PATH:LINE"),
|
||||
(at(&[("src/a.rs:x", "nan")]), "positive number"),
|
||||
(at(&[("src/a.rs:0", "zero")]), "positive number"),
|
||||
(at(&[("src/a.rs:-4", "negative")]), "positive number"),
|
||||
(at(&[(":5", "no path")]), "PATH is empty"),
|
||||
(at(&[("src/a.rs:5", " ")]), "text is empty"),
|
||||
(vec!["src/a.rs:5".to_owned()], "PATH:LINE and a TEXT"),
|
||||
] {
|
||||
let err = format!("{:#}", parse_inline_comments(&pairs).unwrap_err());
|
||||
assert!(err.contains(needle), "{pairs:?} -> {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_at_means_no_inline_comments() {
|
||||
assert!(parse_inline_comments(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn comment(id: i64, position: u64, resolver: Option<&str>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
|
|
|
|||
Loading…
Reference in a new issue