fix: -R works on repo positional commands, null-array deserialization, list endpoint normalization

* `fj repo branches`, `repo topics`, `repo edit`, `repo fork`, `repo sync`,
  `repo archive`, `repo unarchive`, `repo mirror-sync` previously took only
  a positional `repo` argument and rejected `-R/--repo`. Now they accept
  both, with `-R` winning when both are given.
* Forgejo returns `null` for `labels`/`assignees` on issues and PRs when
  empty. The Issue/Pull structs hit `expected a sequence` on every issue
  create. Added a `deserialize_null_to_default` helper on the affected
  fields so null is now coerced to an empty Vec.
* `get_page` similarly bailed when a list endpoint returned a bare `null`
  body. Now treats null and empty body as `[]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 08:41:57 -07:00
parent 21311b6340
commit eb716ee588
No known key found for this signature in database
6 changed files with 58 additions and 12 deletions

View file

@ -16,9 +16,9 @@ pub struct Issue {
pub body: String, pub body: String,
pub state: String, pub state: String,
pub user: User, pub user: User,
#[serde(default)] #[serde(default, deserialize_with = "crate::api::serde_util::deserialize_null_to_default")]
pub labels: Vec<Label>, pub labels: Vec<Label>,
#[serde(default)] #[serde(default, deserialize_with = "crate::api::serde_util::deserialize_null_to_default")]
pub assignees: Vec<User>, pub assignees: Vec<User>,
pub html_url: String, pub html_url: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,

View file

@ -1,5 +1,6 @@
pub mod hook; pub mod hook;
pub mod issue; pub mod issue;
pub(crate) mod serde_util;
pub mod label; pub mod label;
pub mod notification; pub mod notification;
pub mod org; pub mod org;

View file

@ -17,7 +17,7 @@ pub struct Pull {
pub body: String, pub body: String,
pub state: String, pub state: String,
pub user: User, pub user: User,
#[serde(default)] #[serde(default, deserialize_with = "crate::api::serde_util::deserialize_null_to_default")]
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub html_url: String, pub html_url: String,
pub head: Branch, pub head: Branch,

16
src/api/serde_util.rs Normal file
View file

@ -0,0 +1,16 @@
//! Small serde helpers for the Forgejo API's habit of returning `null` in
//! places where most clients expect an empty array.
use serde::{Deserialize, Deserializer};
/// Use as `#[serde(default, deserialize_with = "deserialize_null_to_default")]`
/// on fields that should map `null` to `Default::default()` (typically empty
/// `Vec` or empty `String`).
pub fn deserialize_null_to_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Default + Deserialize<'de>,
{
let opt = Option::<T>::deserialize(d)?;
Ok(opt.unwrap_or_default())
}

View file

@ -69,6 +69,9 @@ pub struct ListArgs {
pub struct ViewArgs { pub struct ViewArgs {
/// `owner/name` slug. Inferred from the git remote when omitted. /// `owner/name` slug. Inferred from the git remote when omitted.
pub repo: Option<String>, pub repo: Option<String>,
/// Alias for the positional repo arg.
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
/// Open the repo's web page. /// Open the repo's web page.
@ -76,6 +79,12 @@ pub struct ViewArgs {
pub web: bool, pub web: bool,
} }
/// Choose between a positional repo arg and the `-R` flag.
fn pick_repo<'a>(pos: Option<&'a String>, flag: Option<&'a String>) -> Option<&'a str> {
flag.map(|s| s.as_str())
.or(pos.map(|s| s.as_str()))
}
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CloneArgs { pub struct CloneArgs {
/// `owner/name` slug. /// `owner/name` slug.
@ -106,6 +115,8 @@ pub struct CreateArgs {
pub struct ForkArgs { pub struct ForkArgs {
/// Source repo `owner/name`. Inferred when omitted. /// Source repo `owner/name`. Inferred when omitted.
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
/// Place the fork under this organization instead of your user account. /// Place the fork under this organization instead of your user account.
#[arg(long)] #[arg(long)]
pub org: Option<String>, pub org: Option<String>,
@ -120,6 +131,8 @@ pub struct ForkArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct SyncArgs { pub struct SyncArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
/// Branch to sync. Defaults to the repo's default branch. /// Branch to sync. Defaults to the repo's default branch.
#[arg(long)] #[arg(long)]
pub branch: Option<String>, pub branch: Option<String>,
@ -128,6 +141,8 @@ pub struct SyncArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct EditArgs { pub struct EditArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
#[arg(long)] #[arg(long)]
pub description: Option<String>, pub description: Option<String>,
#[arg(long)] #[arg(long)]
@ -151,6 +166,8 @@ pub struct RenameArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ArchiveArgs { pub struct ArchiveArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -164,6 +181,8 @@ pub struct DeleteArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct BranchesArgs { pub struct BranchesArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
} }
@ -171,6 +190,8 @@ pub struct BranchesArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct TopicsArgs { pub struct TopicsArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
/// Set the topic list (comma-separated). Omit to just print. /// Set the topic list (comma-separated). Omit to just print.
#[arg(long)] #[arg(long)]
pub set: Option<String>, pub set: Option<String>,
@ -210,6 +231,8 @@ pub struct MirrorArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct MirrorSyncArgs { pub struct MirrorSyncArgs {
pub repo: Option<String>, pub repo: Option<String>,
#[arg(short = 'R', long = "repo")]
pub repo_flag: Option<String>,
} }
pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> { pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> {
@ -272,7 +295,7 @@ async fn mirror(args: MirrorArgs, host: Option<&str>) -> Result<()> {
} }
async fn mirror_sync(args: MirrorSyncArgs, host: Option<&str>) -> Result<()> { async fn mirror_sync(args: MirrorSyncArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
api::repo::mirror_sync(&ctx.client, &ctx.owner, &ctx.name).await?; api::repo::mirror_sync(&ctx.client, &ctx.owner, &ctx.name).await?;
println!("✓ Triggered mirror sync for {}/{}", ctx.owner, ctx.name); println!("✓ Triggered mirror sync for {}/{}", ctx.owner, ctx.name);
Ok(()) Ok(())
@ -319,7 +342,7 @@ async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
} }
async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> { async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?; let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
if args.web { if args.web {
return web::open(&repo.html_url); return web::open(&repo.html_url);
@ -417,7 +440,7 @@ async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
} }
async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> { async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let r = api::repo::fork( let r = api::repo::fork(
&ctx.client, &ctx.client,
&ctx.owner, &ctx.owner,
@ -447,7 +470,7 @@ async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> {
} }
async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> { async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let branch = match args.branch { let branch = match args.branch {
Some(b) => b, Some(b) => b,
None => { None => {
@ -465,7 +488,7 @@ async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> {
} }
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> { async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let description = if args.description_editor { let description = if args.description_editor {
let existing = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?; let existing = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
Some(editor::edit_text( Some(editor::edit_text(
@ -496,7 +519,7 @@ async fn rename(args: RenameArgs, host: Option<&str>) -> Result<()> {
} }
async fn set_archived(args: ArchiveArgs, host: Option<&str>, archived: bool) -> Result<()> { async fn set_archived(args: ArchiveArgs, host: Option<&str>, archived: bool) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let body = api::repo::EditRepo { let body = api::repo::EditRepo {
archived: Some(archived), archived: Some(archived),
..Default::default() ..Default::default()
@ -527,7 +550,7 @@ async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
} }
async fn branches(args: BranchesArgs, host: Option<&str>) -> Result<()> { async fn branches(args: BranchesArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
let bs = api::repo::list_branches(&ctx.client, &ctx.owner, &ctx.name).await?; let bs = api::repo::list_branches(&ctx.client, &ctx.owner, &ctx.name).await?;
if args.json { if args.json {
return output::print_json(&serde_json::to_value(&bs)?); return output::print_json(&serde_json::to_value(&bs)?);
@ -559,7 +582,7 @@ async fn branches(args: BranchesArgs, host: Option<&str>) -> Result<()> {
} }
async fn topics(args: TopicsArgs, host: Option<&str>) -> Result<()> { async fn topics(args: TopicsArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?; let ctx = resolve_repo(pick_repo(args.repo.as_ref(), args.repo_flag.as_ref()), host)?;
if let Some(set) = args.set { if let Some(set) = args.set {
let list: Vec<String> = set let list: Vec<String> = set
.split(',') .split(',')

View file

@ -269,7 +269,13 @@ impl Client {
let res = self.request(Method::GET, path, query, None).await?; let res = self.request(Method::GET, path, query, None).await?;
let res = ensure_success(res).await?; let res = ensure_success(res).await?;
let headers = res.headers().clone(); let headers = res.headers().clone();
let items: Vec<T> = res.json().await.context("decoding JSON list response")?; let text = res.text().await.context("reading list response body")?;
// Forgejo returns `null` for some empty list endpoints. Normalize.
let items: Vec<T> = if text.trim().is_empty() || text.trim() == "null" {
Vec::new()
} else {
serde_json::from_str(&text).context("decoding JSON list response")?
};
Ok(Page::from_headers(items, &headers)) Ok(Page::from_headers(items, &headers))
} }