hive-forge: show + post/remove emoji reactions on issues, PRs, and comments
This commit is contained in:
parent
eb8387bd73
commit
2c45a9960f
9 changed files with 261 additions and 13 deletions
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
96
hive-forge/src/verbs/reaction.rs
Normal file
96
hive-forge/src/verbs/reaction.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue