hive-forge: show + post/remove emoji reactions on issues, PRs, and comments

This commit is contained in:
damocles 2026-08-18 12:37:52 +02:00 committed by mara
commit 2c45a9960f
9 changed files with 261 additions and 13 deletions

View file

@ -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(())
}
}

View file

@ -14,11 +14,9 @@
//! always reported** — never a silent truncation.
//! - `--since <RFC3339>` 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/<n>/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<String>,
/// 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<Value> = 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<String> {
}
}
/// 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

View file

@ -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()),
});

View file

@ -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 <id>`.
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)

View file

@ -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<Vec<Value>> {
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<Vec<Value>> {
let reactions = client
.api()
.issue_get_comment_reactions(owner, name, index(comment_id)?)
.send()?;
Ok(reaction_values(reactions))
}
fn reaction_values(reactions: Vec<Reaction>) -> Vec<Value> {
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<String> {
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::<Vec<_>>()
.join(" "),
)
}
/// A single attachment as one display line — `[file: <name>] <url>`,
/// mirroring the `[file: ...]` marker convention `read_room` already
/// uses for matrix attachments. `None` when the attachment has no

View file

@ -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)
}

View file

@ -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 <id>`.
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)

View file

@ -0,0 +1,96 @@
//! `reaction <number> [--comment <id>] [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 <id>` 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 <n> reaction` / `pr <n> 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<u64>,
#[command(subcommand)]
action: Option<Action>,
}
#[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))
}

View file

@ -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(