fj/src/api/repo.rs
Stephen Way faaf522b05
Some checks are pending
ci / check (push) Waiting to run
bugs + agent-focused Forgejo gaps + CI + docs
Bugs:
* Shell injection in `fj auth setup-git`: the hostname is now validated
  against a strict DNS pattern and `git config` is invoked directly
  (no `sh -c`). Added 4 unit tests covering shell metacharacters.
* Pager won't compile on Windows: the libc-based dup2 redirect lives
  behind `#[cfg(unix)]`. Non-Unix gets a no-op stub.

Agent-focused Forgejo API gaps:
* `fj issue edit-comment ID` / `delete-comment ID`. Fix a wrong comment
  after the fact (an agent's bread-and-butter).
* `fj search code "..." [-R owner/name]`. The most-requested missing
  search dimension for codebase exploration.
* `fj pr request-review N user1 user2`, `unrequest-review N user`.
  Distinct from `pr review` (your own approval/changes/comment).
* `fj repo watch / unwatch / star / unstar / starred`. Mark repos for
  monitoring.
* `fj milestone {list,view,create,edit,close,reopen,delete,assign}`
  with `assign N --milestone ID|none` to attach an issue/PR.

UX + stability:
* Global `--json-fields foo,bar` projection on top of any `--json`
  output, gh-style. Dotted-path support (`--json-fields owner.login`).
* 429 / Retry-After honored in the retry loop with a 30 s cap.
* Clap `suggestions` feature for typo'd subcommands.
* `fj auth token` and `auth status --show-token` refuse to write to a
  TTY by default (`--force` to override).

CI:
* `.forgejo/workflows/ci.yml` runs fmt/clippy/test/release-build on
  every push and PR, mirroring the local pre-push hook.

Docs:
* `SECURITY.md` with threat model and known sharp edges.
* `docs/gh-to-fj.md` full command-by-command mapping.
* `docs/faq.md` covering tokens, hosts, debug, scripting, plugins.

Tests: 60 → 75 passing (2 ignored: editor and env-mutating tests that
fight the cargo test harness on macOS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:52:28 -07:00

361 lines
11 KiB
Rust

use anyhow::Result;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::{Client, Page};
use super::user::User;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repo {
pub id: u64,
pub name: String,
pub full_name: String,
pub owner: User,
#[serde(default)]
pub description: String,
#[serde(default)]
pub private: bool,
#[serde(default)]
pub fork: bool,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub mirror: bool,
pub html_url: String,
pub clone_url: String,
pub ssh_url: String,
#[serde(default)]
pub default_branch: String,
#[serde(default)]
pub stars_count: u64,
#[serde(default)]
pub forks_count: u64,
#[serde(default)]
pub open_issues_count: u64,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ListOptions<'a> {
pub limit: u32,
pub page: u32,
pub query: Option<&'a str>,
}
pub async fn list_for_user(client: &Client, opts: ListOptions<'_>) -> Result<Page<Repo>> {
if opts.limit > 50 {
let items = client
.get_all::<Repo>("/api/v1/user/repos", &[], opts.limit as usize)
.await?;
return Ok(Page::single(items));
}
let limit = opts.limit.clamp(1, 50);
let page = opts.page.max(1);
let query: Vec<(String, String)> = vec![
("limit".into(), limit.to_string()),
("page".into(), page.to_string()),
];
client.get_page::<Repo>("/api/v1/user/repos", &query).await
}
pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result<Page<SearchHit>> {
let limit = opts.limit.clamp(1, 50);
let page = opts.page.max(1);
let mut query: Vec<(String, String)> = vec![
("limit".into(), limit.to_string()),
("page".into(), page.to_string()),
];
if let Some(q) = opts.query {
query.push(("q".into(), q.into()));
}
let res = client
.request(Method::GET, "/api/v1/repos/search", &query, None)
.await?;
let headers = res.headers().clone();
let body: SearchResponse = res.error_for_status()?.json().await?;
Ok(Page::from_headers(body.data, &headers))
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
data: Vec<SearchHit>,
#[serde(default)]
#[allow(dead_code)]
ok: bool,
}
pub type SearchHit = Repo;
pub async fn get(client: &Client, owner: &str, name: &str) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateRepo<'a> {
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(default)]
pub private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<&'a str>,
#[serde(default)]
pub auto_init: bool,
}
pub async fn create_for_current_user(client: &Client, body: &CreateRepo<'_>) -> Result<Repo> {
client
.json(Method::POST, "/api/v1/user/repos", &[], Some(body))
.await
}
pub async fn create_for_org(client: &Client, org: &str, body: &CreateRepo<'_>) -> Result<Repo> {
let path = format!("/api/v1/orgs/{org}/repos");
client.json(Method::POST, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditRepo<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
}
pub async fn edit(client: &Client, owner: &str, name: &str, body: &EditRepo<'_>) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}");
client.json(Method::PATCH, &path, &[], Some(body)).await
}
pub async fn delete(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
struct ForkBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
organization: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
}
pub async fn fork(
client: &Client,
owner: &str,
name: &str,
organization: Option<&str>,
new_name: Option<&str>,
) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}/forks");
client
.json(
Method::POST,
&path,
&[],
Some(&ForkBody {
organization,
name: new_name,
}),
)
.await
}
pub async fn rename(client: &Client, owner: &str, name: &str, new_name: &str) -> Result<()> {
// Forgejo: PATCH /repos/{owner}/{name} with `name` field.
let path = format!("/api/v1/repos/{owner}/{name}");
#[derive(Serialize)]
struct Body<'a> {
name: &'a str,
}
let res = client
.request(
Method::PATCH,
&path,
&[],
Some(&serde_json::to_value(Body { name: new_name })?),
)
.await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct MergeUpstream<'a> {
pub branch: &'a str,
}
pub async fn sync_with_upstream(
client: &Client,
owner: &str,
name: &str,
branch: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/merge-upstream");
let res = client
.request(
Method::POST,
&path,
&[],
Some(&serde_json::to_value(MergeUpstream { branch })?),
)
.await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub name: String,
#[serde(default)]
pub protected: bool,
pub commit: BranchCommit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchCommit {
pub id: String,
#[serde(default)]
pub message: String,
}
pub async fn list_branches(client: &Client, owner: &str, name: &str) -> Result<Vec<Branch>> {
let path = format!("/api/v1/repos/{owner}/{name}/branches");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Topic {
#[serde(default)]
pub topics: Vec<String>,
}
pub async fn list_topics(client: &Client, owner: &str, name: &str) -> Result<Vec<String>> {
let path = format!("/api/v1/repos/{owner}/{name}/topics");
let t: Topic = client.json(Method::GET, &path, &[], None::<&()>).await?;
Ok(t.topics)
}
pub async fn set_topics(client: &Client, owner: &str, name: &str, topics: &[String]) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/topics");
let res = client
.request(
Method::PUT,
&path,
&[],
Some(&serde_json::json!({ "topics": topics })),
)
.await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct Migrate<'a> {
pub clone_addr: &'a str,
pub repo_name: &'a str,
/// User or org login that will own the migrated repo.
pub repo_owner: &'a str,
#[serde(default)]
pub mirror: bool,
#[serde(default)]
pub private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_password: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_token: Option<&'a str>,
/// Service: "git", "github", "gitea", "gitlab", "gogs", "onedev", ...
pub service: &'a str,
/// For pull-mirror: how often to refresh from the source.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_interval: Option<&'a str>,
}
pub async fn migrate(client: &Client, body: &Migrate<'_>) -> Result<Repo> {
client
.json(Method::POST, "/api/v1/repos/migrate", &[], Some(body))
.await
}
// Watch / star ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Subscription {
#[serde(default)]
pub subscribed: bool,
#[serde(default)]
pub ignored: bool,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub url: String,
}
#[allow(dead_code)]
pub async fn get_subscription(client: &Client, owner: &str, name: &str) -> Result<Subscription> {
let path = format!("/api/v1/repos/{owner}/{name}/subscription");
client.json(Method::GET, &path, &[], None::<&()>).await
}
pub async fn watch(client: &Client, owner: &str, name: &str) -> Result<Subscription> {
let path = format!("/api/v1/repos/{owner}/{name}/subscription");
client
.json(
Method::PUT,
&path,
&[],
Some(&serde_json::json!({ "subscribed": true, "ignored": false })),
)
.await
}
pub async fn unwatch(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/subscription");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
pub async fn star(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/user/starred/{owner}/{name}");
let res = client.request(Method::PUT, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
pub async fn unstar(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/user/starred/{owner}/{name}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
pub async fn list_starred(client: &Client, limit: u32) -> Result<Vec<Repo>> {
let q = vec![("limit".into(), limit.clamp(1, 50).to_string())];
client
.json(Method::GET, "/api/v1/user/starred", &q, None::<&()>)
.await
}
pub async fn mirror_sync(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/mirror-sync");
let res = client.request(Method::POST, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}