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

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