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:
parent
21311b6340
commit
eb716ee588
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
16
src/api/serde_util.rs
Normal 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())
|
||||||
|
}
|
||||||
|
|
@ -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(',')
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue