diff --git a/hive-forge/src/verbs/comment_show.rs b/hive-forge/src/verbs/comment_show.rs index e3e61e1e..4f45facc 100644 --- a/hive-forge/src/verbs/comment_show.rs +++ b/hive-forge/src/verbs/comment_show.rs @@ -6,7 +6,9 @@ use clap::Args as ClapArgs; use serde_json::json; use crate::client::{Client, index}; -use crate::verbs::{attachment_json, attachment_line, print_json, rfc3339}; +use crate::verbs::{ + attachment_json, attachment_line, comment_reactions, print_json, reaction_summary, rfc3339, +}; #[derive(ClapArgs)] pub struct Args { @@ -23,6 +25,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { else { bail!("hive-forge comment-show: comment {} not found", args.id); }; + let reactions = comment_reactions(client, owner, name, args.id)?; if client.json_mode() { let trimmed = json!({ "id": c.id, @@ -32,6 +35,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "body": c.body, "url": c.html_url, "attachments": attachment_json(c.assets.as_deref()), + "reactions": reactions, }); print_json(&trimmed) } else { @@ -42,6 +46,9 @@ pub fn run(client: &Client, args: Args) -> Result<()> { println!("{line}"); } } + if let Some(summary) = reaction_summary(&reactions) { + println!("[reactions: {summary}]"); + } Ok(()) } } diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 8066e2d0..1d7a3190 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -14,11 +14,9 @@ //! always reported** — never a silent truncation. //! - `--since ` filters to comments at or after that //! timestamp instead of a head/tail window — a cursor: feed the -//! last-seen row's own `created_at` back in next time to fetch only -//! what's new. Mutually exclusive with `--tail`. No total exists for -//! a since-filtered query, so this uses the same over-fetch-by-one -//! trick `timeline`'s `--limit` does, and `--limit` is clamped the -//! same way (see `crate::verbs::MAX_LIMIT`). +//! last-seen row's own `created_at` back in next time. Mutually +//! exclusive with `--tail`; `--limit` clamps like `timeline`'s own +//! does (see `crate::verbs::MAX_LIMIT`). //! //! Review *bodies* on PRs are always merged in too (`pulls//reviews` //! isn't the issues/comments thread), tagged `[review: STATE]`, with a @@ -27,7 +25,8 @@ //! //! `--json` output is an object (`{"comments": [...], "more_before": N, //! "more_after": N, "since_more": bool}`), not a bare array, so a -//! script can read the truncation info too. +//! script can read the truncation info too. `--show-reactions` adds a +//! per-comment reaction summary — see that flag's own doc for the cost. use anyhow::Result; use clap::Args as ClapArgs; @@ -37,7 +36,10 @@ use time::OffsetDateTime; use crate::client::{Client, index}; use crate::notify; -use crate::verbs::{MAX_LIMIT, PAGE_SIZE, clamp_limit, parse_rfc3339, print_json, rfc3339}; +use crate::verbs::{ + MAX_LIMIT, PAGE_SIZE, clamp_limit, comment_reactions, parse_rfc3339, print_json, + reaction_summary, rfc3339, +}; #[derive(ClapArgs)] pub struct Args { @@ -58,6 +60,10 @@ pub struct Args { /// `--tail`. #[arg(long)] since: Option, + /// Fetch + display each shown comment's reaction summary. Costs one + /// extra request per comment shown — opt-in, not the default. + #[arg(long)] + show_reactions: bool, } pub fn run(client: &Client, args: Args) -> Result<()> { @@ -90,10 +96,12 @@ pub fn run(client: &Client, args: Args) -> Result<()> { }; // Merge in PR review bodies (empty for issues — degrades to a // no-op) so review feedback isn't silently dropped. - let comments = merge_chronological(thread, fetch_review_bodies(client, args.number)); + let mut comments = merge_chronological(thread, fetch_review_bodies(client, args.number)); // Reading the thread clears its unread notification so the // read-before-comment guard (in `comment`) lets a reply through. notify::mark_read_best_effort(client, repo, args.number); + let (owner, name) = client.owner_repo()?; + attach_reactions(client, owner, name, &mut comments, args.show_reactions); if client.json_mode() { let trimmed: Vec = comments .iter() @@ -109,6 +117,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "state": c.get("state"), "comments_count": c.get("comments_count"), "attachments": attachment_json_from_value(c), + "reactions": c.get("reactions").cloned().unwrap_or_else(|| json!([])), }) }) .collect(); @@ -145,6 +154,13 @@ pub fn run(client: &Client, args: Args) -> Result<()> { for line in attachment_lines_from_value(c) { println!("{line}"); } + if let Some(summary) = c + .get("reactions") + .and_then(Value::as_array) + .and_then(|r| reaction_summary(r)) + { + println!("[reactions: {summary}]"); + } println!(); } if since_clamped { @@ -184,6 +200,39 @@ fn truncation_note(more_before: usize, more_after: usize) -> Option { } } +/// Fetch + attach each shown comment's reaction list under a +/// `"reactions"` key, mutating in place so both the `--json` and +/// pretty-print branches see the same fetch. No-op when `show_reactions` +/// is false (the default) — the whole point of gating this behind a +/// flag is that a plain `comments` call costs nothing extra. Review +/// entries are skipped (synthesized, never have real reactions); a +/// per-comment fetch failure is swallowed rather than failing the whole +/// listing over one comment. +fn attach_reactions( + client: &Client, + owner: &str, + name: &str, + comments: &mut [Value], + show_reactions: bool, +) { + if !show_reactions { + return; + } + for c in comments.iter_mut() { + if c.get("kind").and_then(Value::as_str) == Some("review") { + continue; + } + let Some(id) = c.get("id").and_then(Value::as_u64) else { + continue; + }; + if let Ok(reactions) = comment_reactions(client, owner, name, id) + && let Some(obj) = c.as_object_mut() + { + obj.insert("reactions".to_owned(), json!(reactions)); + } + } +} + /// Attachment display lines for a single comment's already-serialized /// `assets` field — the shape this module's merge/render pipeline works /// on (comments arrive as `Value`s, not the typed `Comment` struct, once diff --git a/hive-forge/src/verbs/issue.rs b/hive-forge/src/verbs/issue.rs index 2f31796c..50c8a692 100644 --- a/hive-forge/src/verbs/issue.rs +++ b/hive-forge/src/verbs/issue.rs @@ -5,7 +5,7 @@ use clap::Args as ClapArgs; use serde_json::json; use crate::client::{Client, index}; -use crate::verbs::{attachment_json, dependency_summaries, print_json}; +use crate::verbs::{attachment_json, dependency_summaries, issue_reactions, print_json}; #[derive(ClapArgs)] pub struct Args { @@ -34,6 +34,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .filter_map(|l| l.name.as_deref()) .collect(); let dependencies = dependency_summaries(client, owner, name, args.number)?; + let reactions = issue_reactions(client, owner, name, args.number)?; let trimmed = json!({ "number": issue.number, "title": issue.title, @@ -42,6 +43,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "assignees": assignees, "labels": labels, "dependencies": dependencies, + "reactions": reactions, "body": issue.body, "attachments": attachment_json(issue.assets.as_deref()), }); diff --git a/hive-forge/src/verbs/issue_cmd.rs b/hive-forge/src/verbs/issue_cmd.rs index ec9fd864..245e4c68 100644 --- a/hive-forge/src/verbs/issue_cmd.rs +++ b/hive-forge/src/verbs/issue_cmd.rs @@ -42,6 +42,9 @@ enum Cmd { Assign(verbs::assign::Args), /// List / add / remove dependencies (issues this one is blocked by). Dependency(verbs::dependency::Args), + /// List / add / remove emoji reactions on the issue, or on one of its + /// comments with `--comment `. + Reaction(verbs::reaction::Args), /// List timeline events. Timeline(verbs::timeline::Args), } @@ -85,6 +88,10 @@ pub fn run(client: &Client, args: Args) -> Result<()> { assert_kind(client, a.number, Kind::Issue)?; verbs::dependency::run(client, a) } + Cmd::Reaction(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::reaction::run(client, a) + } Cmd::Timeline(a) => { assert_kind(client, a.number, Kind::Issue)?; verbs::timeline::run(client, a) diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 0a49f161..df0f3f6f 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -34,6 +34,7 @@ pub mod pr_create; pub mod pr_merge; pub mod pr_reviews; pub mod pr_status; +pub mod reaction; pub mod reopen; pub mod repo_add_collaborator; pub mod repo_create; @@ -47,7 +48,7 @@ pub mod view; use std::fmt::Write as _; use anyhow::Result; -use forgejo_api::structs::Attachment; +use forgejo_api::structs::{Attachment, Reaction}; use serde_json::{Value, json}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -380,6 +381,79 @@ pub(crate) fn dependency_summaries( .collect()) } +/// The current reactions on an issue or PR itself (not a comment) — each +/// entry's `content` is Forgejo's shortcode (`"+1"`, `"heart"`, …, the +/// same vocabulary GitHub uses), `user` its login. Same shared-index +/// rationale as [`dependency_summaries`]: PRs are issues internally, so +/// `issue`/`pr`/`view` and the `reaction` verb's own listing all call +/// this instead of duplicating the fetch-and-shape step. +/// +/// # Errors +/// +/// Propagates the forge API errors from listing reactions. +pub(crate) fn issue_reactions( + client: &Client, + owner: &str, + name: &str, + number: u64, +) -> Result> { + let (_, reactions) = client + .api() + .issue_get_issue_reactions(owner, name, index(number)?) + .send()?; + Ok(reaction_values(reactions)) +} + +/// The current reactions on a single comment, by comment id (not the +/// parent issue/PR number — Forgejo's comment-reaction endpoints are +/// keyed on the comment alone, same as [`comment_show`]'s lookup). +/// +/// # Errors +/// +/// Propagates the forge API errors from listing reactions. +pub(crate) fn comment_reactions( + client: &Client, + owner: &str, + name: &str, + comment_id: u64, +) -> Result> { + let reactions = client + .api() + .issue_get_comment_reactions(owner, name, index(comment_id)?) + .send()?; + Ok(reaction_values(reactions)) +} + +fn reaction_values(reactions: Vec) -> Vec { + reactions + .into_iter() + .map(|r| json!({ "content": r.content, "user": r.user.and_then(|u| u.login) })) + .collect() +} + +/// A one-line `content×count` summary of a reaction list (e.g. `+1×2 +/// heart×1`), grouped and sorted by content name for a stable rendering. +/// `None` for an empty list, so a row/section with no reactions omits the +/// summary entirely rather than printing something empty. +pub(crate) fn reaction_summary(reactions: &[Value]) -> Option { + let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for r in reactions { + if let Some(content) = r.get("content").and_then(Value::as_str) { + *counts.entry(content).or_insert(0) += 1; + } + } + if counts.is_empty() { + return None; + } + Some( + counts + .into_iter() + .map(|(content, n)| format!("{content}×{n}")) + .collect::>() + .join(" "), + ) +} + /// A single attachment as one display line — `[file: ] `, /// mirroring the `[file: ...]` marker convention `read_room` already /// uses for matrix attachments. `None` when the attachment has no diff --git a/hive-forge/src/verbs/pr.rs b/hive-forge/src/verbs/pr.rs index 0e3d7c4c..0302d218 100644 --- a/hive-forge/src/verbs/pr.rs +++ b/hive-forge/src/verbs/pr.rs @@ -5,7 +5,7 @@ use clap::Args as ClapArgs; use serde_json::json; use crate::client::{Client, index}; -use crate::verbs::{dependency_summaries, print_json}; +use crate::verbs::{dependency_summaries, issue_reactions, print_json}; #[derive(ClapArgs)] pub struct Args { @@ -20,6 +20,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .repo_get_pull_request(owner, name, index(args.number)?) .send()?; let dependencies = dependency_summaries(client, owner, name, args.number)?; + let reactions = issue_reactions(client, owner, name, args.number)?; let trimmed = json!({ "number": pull.number, "title": pull.title, @@ -30,6 +31,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "head_branch": pull.head.as_ref().and_then(|h| h.label.as_deref()), "base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()), "dependencies": dependencies, + "reactions": reactions, }); print_json(&trimmed) } diff --git a/hive-forge/src/verbs/pr_cmd.rs b/hive-forge/src/verbs/pr_cmd.rs index de94548c..ef1eee4f 100644 --- a/hive-forge/src/verbs/pr_cmd.rs +++ b/hive-forge/src/verbs/pr_cmd.rs @@ -57,6 +57,9 @@ enum Cmd { AssignCommitter(verbs::assign::Args), /// List / add / remove dependencies (issues/PRs this one is blocked by). Dependency(verbs::dependency::Args), + /// List / add / remove emoji reactions on the PR, or on one of its + /// comments with `--comment `. + Reaction(verbs::reaction::Args), /// List timeline events. Timeline(verbs::timeline::Args), } @@ -109,6 +112,10 @@ pub fn run(client: &Client, args: Args) -> Result<()> { assert_kind(client, a.number, Kind::Pr)?; verbs::dependency::run(client, a) } + Cmd::Reaction(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::reaction::run(client, a) + } Cmd::Timeline(a) => { assert_kind(client, a.number, Kind::Pr)?; verbs::timeline::run(client, a) diff --git a/hive-forge/src/verbs/reaction.rs b/hive-forge/src/verbs/reaction.rs new file mode 100644 index 00000000..2fa98005 --- /dev/null +++ b/hive-forge/src/verbs/reaction.rs @@ -0,0 +1,96 @@ +//! `reaction [--comment ] [list|add|remove] [content]` — +//! list, add, or remove emoji reactions on an issue/PR or one of its +//! comments. +//! +//! Forgejo's reaction endpoints work on the shared issue/PR index (PRs +//! are issues internally, same as `dependency`) when no `--comment` is +//! given; `--comment ` redirects to the separate comment-reaction +//! endpoint pair, keyed on the comment's own id (not the parent +//! issue/PR number — `number` still has to be passed so this verb slots +//! into `issue reaction` / `pr reaction` like every other +//! generic verb, but it's otherwise unused on that path). +//! +//! `content` is Forgejo's reaction shortcode vocabulary (`+1`, `-1`, +//! `laugh`, `confused`, `heart`, `hooray`, `rocket`, `eyes`, …), not a +//! raw emoji character — same set the forge web UI's reaction picker +//! offers. + +use anyhow::Result; +use clap::{Args as ClapArgs, Subcommand}; +use forgejo_api::structs::EditReactionOption; + +use crate::client::{Client, index}; +use crate::verbs::{comment_reactions, issue_reactions, print_json}; + +#[derive(ClapArgs)] +pub struct Args { + /// Issue or PR number. + pub(crate) number: u64, + /// Target a specific comment's reactions instead of the issue/PR + /// itself — the comment's own id (from `comments`/`comment-show`), + /// not its position in the thread. + #[arg(long)] + comment: Option, + #[command(subcommand)] + action: Option, +} + +#[derive(Subcommand)] +enum Action { + /// List reactions (default when no action is given). + List, + /// Add a reaction — a Forgejo shortcode, e.g. `+1`, `heart`, `rocket`. + Add { content: String }, + /// Remove your own reaction with this content. + Remove { content: String }, +} + +pub fn run(client: &Client, args: Args) -> Result<()> { + let (owner, name) = client.owner_repo()?; + match args.action.unwrap_or(Action::List) { + Action::List => {} + Action::Add { content } => { + let body = EditReactionOption { + content: Some(content), + }; + match args.comment { + Some(id) => { + client + .api() + .issue_post_comment_reaction(owner, name, index(id)?, body) + .send()?; + } + None => { + client + .api() + .issue_post_issue_reaction(owner, name, index(args.number)?, body) + .send()?; + } + } + } + Action::Remove { content } => { + let body = EditReactionOption { + content: Some(content), + }; + match args.comment { + Some(id) => { + client + .api() + .issue_delete_comment_reaction(owner, name, index(id)?, body) + .send()?; + } + None => { + client + .api() + .issue_delete_issue_reaction(owner, name, index(args.number)?, body) + .send()?; + } + } + } + } + let reactions = match args.comment { + Some(id) => comment_reactions(client, owner, name, id)?, + None => issue_reactions(client, owner, name, args.number)?, + }; + print_json(&serde_json::json!(reactions)) +} diff --git a/hive-forge/src/verbs/view.rs b/hive-forge/src/verbs/view.rs index 5f46da1d..63fc226c 100644 --- a/hive-forge/src/verbs/view.rs +++ b/hive-forge/src/verbs/view.rs @@ -7,7 +7,7 @@ use forgejo_api::structs::{IssueGetCommentsQuery, StateType}; use crate::client::{Client, index}; use crate::notify; -use crate::verbs::attachment_line; +use crate::verbs::{attachment_line, issue_reactions, reaction_summary}; #[derive(ClapArgs)] pub struct Args { @@ -53,6 +53,10 @@ pub fn run(client: &Client, args: Args) -> Result<()> { println!("{line}"); } } + let reactions = issue_reactions(client, owner, name, args.number)?; + if let Some(summary) = reaction_summary(&reactions) { + println!("[reactions: {summary}]"); + } let (_, comments) = client .api() .issue_get_comments(