Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9cc5635a | ||
|
|
96a27cef3c | ||
|
|
2c45a9960f |
11 changed files with 308 additions and 16 deletions
|
|
@ -21,7 +21,7 @@ under `issue` and `pr` parent commands — `hive-forge pr close 42`,
|
||||||
refuses an issue number, which the old generic `close` couldn't). Run
|
refuses an issue number, which the old generic `close` couldn't). Run
|
||||||
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
|
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
|
||||||
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
|
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
|
||||||
comments/close/reopen/labels/assign/dependency/timeline as applicable).
|
comments/close/reopen/labels/assign/dependency/reaction/timeline as applicable).
|
||||||
|
|
||||||
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
|
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
|
||||||
as **hidden back-compat aliases** during the transition and are dropped from
|
as **hidden back-compat aliases** during the transition and are dropped from
|
||||||
|
|
@ -53,6 +53,11 @@ hive-forge issue dependency 42 # list #42's dependencies (issues
|
||||||
hive-forge issue dependency 42 add 40 41 # #42 is now blocked by #40 and #41
|
hive-forge issue dependency 42 add 40 41 # #42 is now blocked by #40 and #41
|
||||||
hive-forge issue dependency 42 remove 40 # drop the #40 dependency link
|
hive-forge issue dependency 42 remove 40 # drop the #40 dependency link
|
||||||
hive-forge pr dependency 55 add 42 # same verb, PR-scoped (Forgejo shares the issue/PR index)
|
hive-forge pr dependency 55 add 42 # same verb, PR-scoped (Forgejo shares the issue/PR index)
|
||||||
|
hive-forge issue reaction 42 # list #42's reactions
|
||||||
|
hive-forge issue reaction 42 add heart # react to #42 with :heart:
|
||||||
|
hive-forge issue reaction 42 --comment 18042 add +1 # react to a specific comment instead
|
||||||
|
hive-forge issue reaction 42 remove heart # remove your own :heart: reaction
|
||||||
|
hive-forge issue reaction 1 --list-allowed # this instance's actual configured shortcodes
|
||||||
hive-forge issue-create --title "..." --body "..."
|
hive-forge issue-create --title "..." --body "..."
|
||||||
hive-forge issue-create --title "..." --body "..." --label area/ops --label type/bug # repeatable
|
hive-forge issue-create --title "..." --body "..." --label area/ops --label type/bug # repeatable
|
||||||
hive-forge issue-edit 42 --title "new title"
|
hive-forge issue-edit 42 --title "new title"
|
||||||
|
|
@ -287,6 +292,18 @@ to discover valid label names before triaging or to audit the label set.
|
||||||
forge UI's dependency panel). Same-repo only, matching the web UI (no
|
forge UI's dependency panel). Same-repo only, matching the web UI (no
|
||||||
cross-repo dependency support). `list` (the default action) and `add`/
|
cross-repo dependency support). `list` (the default action) and `add`/
|
||||||
`remove` all print the resulting dependency list as JSON.
|
`remove` all print the resulting dependency list as JSON.
|
||||||
|
- `issue reaction <n> add <content>` / `pr reaction <n> add <content>` add
|
||||||
|
an emoji reaction (Forgejo's shortcode vocabulary — `+1`, `heart`,
|
||||||
|
`rocket`, …, same set the web UI's picker offers, not a raw emoji
|
||||||
|
character); `--comment <id>` targets a specific comment's reactions
|
||||||
|
instead of the issue/PR itself. `list`/`add`/`remove` all print the
|
||||||
|
resulting reaction list as JSON, same shape as `dependency`. `view`,
|
||||||
|
`issue`, `pr`, and `comment-show` always show a reaction summary;
|
||||||
|
`comments --show-reactions` adds one per comment shown (opt-in — one
|
||||||
|
extra request per comment, no server-side inline count). `reaction
|
||||||
|
<n> --list-allowed` prints the instance's actual configured shortcode
|
||||||
|
list (`GET /settings/ui`'s `allowed_reactions`) instead of guessing —
|
||||||
|
instance-global, `<n>`/`--comment` are ignored on this path.
|
||||||
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
|
- Do NOT use raw `curl` for forge access -- the CLI handles auth,
|
||||||
error checking, and output formatting.
|
error checking, and output formatting.
|
||||||
- `issue-create --label <name>` / `pr-create --label <name>` are
|
- `issue-create --label <name>` / `pr-create --label <name>` are
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ enum Verb {
|
||||||
/// Dump title + body + all comments for an issue or PR.
|
/// Dump title + body + all comments for an issue or PR.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
View(verbs::view::Args),
|
View(verbs::view::Args),
|
||||||
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|dependency|timeline> …`.
|
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|dependency|reaction|timeline> …`.
|
||||||
Issue(verbs::issue_cmd::Args),
|
Issue(verbs::issue_cmd::Args),
|
||||||
/// Create an issue. Prints the issue URL on success.
|
/// Create an issue. Prints the issue URL on success.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
|
|
@ -75,7 +75,7 @@ enum Verb {
|
||||||
/// Edit an issue's title, body, state, or milestone.
|
/// Edit an issue's title, body, state, or milestone.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
IssueEdit(verbs::issue_edit::Args),
|
IssueEdit(verbs::issue_edit::Args),
|
||||||
/// PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|timeline> …`.
|
/// PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`.
|
||||||
Pr(verbs::pr_cmd::Args),
|
Pr(verbs::pr_cmd::Args),
|
||||||
/// List a PR's commits as JSON (sha, message, author date, author).
|
/// List a PR's commits as JSON (sha, message, author date, author).
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -23,6 +25,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
else {
|
else {
|
||||||
bail!("hive-forge comment-show: comment {} not found", args.id);
|
bail!("hive-forge comment-show: comment {} not found", args.id);
|
||||||
};
|
};
|
||||||
|
let reactions = comment_reactions(client, owner, name, args.id)?;
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
|
|
@ -32,6 +35,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
"body": c.body,
|
"body": c.body,
|
||||||
"url": c.html_url,
|
"url": c.html_url,
|
||||||
"attachments": attachment_json(c.assets.as_deref()),
|
"attachments": attachment_json(c.assets.as_deref()),
|
||||||
|
"reactions": reactions,
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -42,6 +46,9 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(summary) = reaction_summary(&reactions) {
|
||||||
|
println!("[reactions: {summary}]");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
//! always reported** — never a silent truncation.
|
//! always reported** — never a silent truncation.
|
||||||
//! - `--since <RFC3339>` filters to comments at or after that
|
//! - `--since <RFC3339>` filters to comments at or after that
|
||||||
//! timestamp instead of a head/tail window — a cursor: feed the
|
//! 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
|
//! last-seen row's own `created_at` back in next time. Mutually
|
||||||
//! what's new. Mutually exclusive with `--tail`. No total exists for
|
//! exclusive with `--tail`; `--limit` clamps like `timeline`'s own
|
||||||
//! a since-filtered query, so this uses the same over-fetch-by-one
|
//! does (see `crate::verbs::MAX_LIMIT`).
|
||||||
//! trick `timeline`'s `--limit` does, and `--limit` is clamped the
|
|
||||||
//! same way (see `crate::verbs::MAX_LIMIT`).
|
|
||||||
//!
|
//!
|
||||||
//! Review *bodies* on PRs are always merged in too (`pulls/<n>/reviews`
|
//! Review *bodies* on PRs are always merged in too (`pulls/<n>/reviews`
|
||||||
//! isn't the issues/comments thread), tagged `[review: STATE]`, with a
|
//! isn't the issues/comments thread), tagged `[review: STATE]`, with a
|
||||||
|
|
@ -27,7 +25,8 @@
|
||||||
//!
|
//!
|
||||||
//! `--json` output is an object (`{"comments": [...], "more_before": N,
|
//! `--json` output is an object (`{"comments": [...], "more_before": N,
|
||||||
//! "more_after": N, "since_more": bool}`), not a bare array, so a
|
//! "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 anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
|
@ -37,7 +36,10 @@ use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::{Client, index};
|
||||||
use crate::notify;
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -58,6 +60,10 @@ pub struct Args {
|
||||||
/// `--tail`.
|
/// `--tail`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
since: Option<String>,
|
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<()> {
|
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
|
// Merge in PR review bodies (empty for issues — degrades to a
|
||||||
// no-op) so review feedback isn't silently dropped.
|
// 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
|
// Reading the thread clears its unread notification so the
|
||||||
// read-before-comment guard (in `comment`) lets a reply through.
|
// read-before-comment guard (in `comment`) lets a reply through.
|
||||||
notify::mark_read_best_effort(client, repo, args.number);
|
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() {
|
if client.json_mode() {
|
||||||
let trimmed: Vec<Value> = comments
|
let trimmed: Vec<Value> = comments
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -109,6 +117,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
"state": c.get("state"),
|
"state": c.get("state"),
|
||||||
"comments_count": c.get("comments_count"),
|
"comments_count": c.get("comments_count"),
|
||||||
"attachments": attachment_json_from_value(c),
|
"attachments": attachment_json_from_value(c),
|
||||||
|
"reactions": c.get("reactions").cloned().unwrap_or_else(|| json!([])),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -145,6 +154,13 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
for line in attachment_lines_from_value(c) {
|
for line in attachment_lines_from_value(c) {
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
|
if let Some(summary) = c
|
||||||
|
.get("reactions")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|r| reaction_summary(r))
|
||||||
|
{
|
||||||
|
println!("[reactions: {summary}]");
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
if since_clamped {
|
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
|
/// Attachment display lines for a single comment's already-serialized
|
||||||
/// `assets` field — the shape this module's merge/render pipeline works
|
/// `assets` field — the shape this module's merge/render pipeline works
|
||||||
/// on (comments arrive as `Value`s, not the typed `Comment` struct, once
|
/// 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 serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -34,6 +34,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.filter_map(|l| l.name.as_deref())
|
.filter_map(|l| l.name.as_deref())
|
||||||
.collect();
|
.collect();
|
||||||
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
||||||
|
let reactions = issue_reactions(client, owner, name, args.number)?;
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": issue.number,
|
"number": issue.number,
|
||||||
"title": issue.title,
|
"title": issue.title,
|
||||||
|
|
@ -42,6 +43,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
"assignees": assignees,
|
"assignees": assignees,
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
|
"reactions": reactions,
|
||||||
"body": issue.body,
|
"body": issue.body,
|
||||||
"attachments": attachment_json(issue.assets.as_deref()),
|
"attachments": attachment_json(issue.assets.as_deref()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ enum Cmd {
|
||||||
Assign(verbs::assign::Args),
|
Assign(verbs::assign::Args),
|
||||||
/// List / add / remove dependencies (issues this one is blocked by).
|
/// List / add / remove dependencies (issues this one is blocked by).
|
||||||
Dependency(verbs::dependency::Args),
|
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.
|
/// List timeline events.
|
||||||
Timeline(verbs::timeline::Args),
|
Timeline(verbs::timeline::Args),
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +88,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
assert_kind(client, a.number, Kind::Issue)?;
|
assert_kind(client, a.number, Kind::Issue)?;
|
||||||
verbs::dependency::run(client, a)
|
verbs::dependency::run(client, a)
|
||||||
}
|
}
|
||||||
|
Cmd::Reaction(a) => {
|
||||||
|
// `--list-allowed` is instance-global, not issue-scoped — skip
|
||||||
|
// the kind-assert rather than force a real issue number on a
|
||||||
|
// call that never uses it.
|
||||||
|
if !a.list_allowed {
|
||||||
|
assert_kind(client, a.number, Kind::Issue)?;
|
||||||
|
}
|
||||||
|
verbs::reaction::run(client, a)
|
||||||
|
}
|
||||||
Cmd::Timeline(a) => {
|
Cmd::Timeline(a) => {
|
||||||
assert_kind(client, a.number, Kind::Issue)?;
|
assert_kind(client, a.number, Kind::Issue)?;
|
||||||
verbs::timeline::run(client, a)
|
verbs::timeline::run(client, a)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ pub mod pr_create;
|
||||||
pub mod pr_merge;
|
pub mod pr_merge;
|
||||||
pub mod pr_reviews;
|
pub mod pr_reviews;
|
||||||
pub mod pr_status;
|
pub mod pr_status;
|
||||||
|
pub mod reaction;
|
||||||
pub mod reopen;
|
pub mod reopen;
|
||||||
pub mod repo_add_collaborator;
|
pub mod repo_add_collaborator;
|
||||||
pub mod repo_create;
|
pub mod repo_create;
|
||||||
|
|
@ -47,7 +48,7 @@ pub mod view;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use forgejo_api::structs::Attachment;
|
use forgejo_api::structs::{Attachment, Reaction};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
@ -380,6 +381,79 @@ pub(crate) fn dependency_summaries(
|
||||||
.collect())
|
.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>`,
|
/// A single attachment as one display line — `[file: <name>] <url>`,
|
||||||
/// mirroring the `[file: ...]` marker convention `read_room` already
|
/// mirroring the `[file: ...]` marker convention `read_room` already
|
||||||
/// uses for matrix attachments. `None` when the attachment has no
|
/// uses for matrix attachments. `None` when the attachment has no
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::{Client, index};
|
||||||
use crate::verbs::{dependency_summaries, print_json};
|
use crate::verbs::{dependency_summaries, issue_reactions, print_json};
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -20,6 +20,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.repo_get_pull_request(owner, name, index(args.number)?)
|
.repo_get_pull_request(owner, name, index(args.number)?)
|
||||||
.send()?;
|
.send()?;
|
||||||
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
||||||
|
let reactions = issue_reactions(client, owner, name, args.number)?;
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": pull.number,
|
"number": pull.number,
|
||||||
"title": pull.title,
|
"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()),
|
"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()),
|
"base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()),
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
|
"reactions": reactions,
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ enum Cmd {
|
||||||
AssignCommitter(verbs::assign::Args),
|
AssignCommitter(verbs::assign::Args),
|
||||||
/// List / add / remove dependencies (issues/PRs this one is blocked by).
|
/// List / add / remove dependencies (issues/PRs this one is blocked by).
|
||||||
Dependency(verbs::dependency::Args),
|
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.
|
/// List timeline events.
|
||||||
Timeline(verbs::timeline::Args),
|
Timeline(verbs::timeline::Args),
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +112,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
assert_kind(client, a.number, Kind::Pr)?;
|
assert_kind(client, a.number, Kind::Pr)?;
|
||||||
verbs::dependency::run(client, a)
|
verbs::dependency::run(client, a)
|
||||||
}
|
}
|
||||||
|
Cmd::Reaction(a) => {
|
||||||
|
// `--list-allowed` is instance-global, not PR-scoped — skip the
|
||||||
|
// kind-assert rather than force a real PR number on a call that
|
||||||
|
// never uses it.
|
||||||
|
if !a.list_allowed {
|
||||||
|
assert_kind(client, a.number, Kind::Pr)?;
|
||||||
|
}
|
||||||
|
verbs::reaction::run(client, a)
|
||||||
|
}
|
||||||
Cmd::Timeline(a) => {
|
Cmd::Timeline(a) => {
|
||||||
assert_kind(client, a.number, Kind::Pr)?;
|
assert_kind(client, a.number, Kind::Pr)?;
|
||||||
verbs::timeline::run(client, a)
|
verbs::timeline::run(client, a)
|
||||||
|
|
|
||||||
113
hive-forge/src/verbs/reaction.rs
Normal file
113
hive-forge/src/verbs/reaction.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
//! `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. `--list-allowed` prints the *actual* instance-configured
|
||||||
|
//! list (`GET /settings/ui`'s `allowed_reactions`) instead of leaving a
|
||||||
|
//! caller to guess it or learn it from a 403 — this is instance-global,
|
||||||
|
//! not issue/PR-scoped, so `number`/`--comment` are ignored when it's
|
||||||
|
//! set (the number is still required positionally, same as every other
|
||||||
|
//! generic verb, but unused on this path).
|
||||||
|
|
||||||
|
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. Ignored (but still required) when
|
||||||
|
/// `--list-allowed` is set.
|
||||||
|
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>,
|
||||||
|
/// Print the instance's actual configured reaction shortcodes
|
||||||
|
/// (`GET /settings/ui`) instead of listing/adding/removing —
|
||||||
|
/// instance-global, ignores `number`/`--comment`.
|
||||||
|
#[arg(long)]
|
||||||
|
pub(crate) list_allowed: bool,
|
||||||
|
#[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<()> {
|
||||||
|
if args.list_allowed {
|
||||||
|
let settings = client.api().get_general_ui_settings().send()?;
|
||||||
|
return print_json(&serde_json::json!(
|
||||||
|
settings.allowed_reactions.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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::client::{Client, index};
|
||||||
use crate::notify;
|
use crate::notify;
|
||||||
use crate::verbs::attachment_line;
|
use crate::verbs::{attachment_line, issue_reactions, reaction_summary};
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -53,6 +53,10 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let reactions = issue_reactions(client, owner, name, args.number)?;
|
||||||
|
if let Some(summary) = reaction_summary(&reactions) {
|
||||||
|
println!("[reactions: {summary}]");
|
||||||
|
}
|
||||||
let (_, comments) = client
|
let (_, comments) = client
|
||||||
.api()
|
.api()
|
||||||
.issue_get_comments(
|
.issue_get_comments(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue