Compare commits
61 changed files with 1914 additions and 2740 deletions
|
|
@ -32,9 +32,8 @@ hand-maintained per-file tree drifts out of sync with the code.
|
||||||
the meta flake, lifecycle (`nixos-container` shellouts), gateway /
|
the meta flake, lifecycle (`nixos-container` shellouts), gateway /
|
||||||
forge / matrix provisioning, per-container stats, and the axum
|
forge / matrix provisioning, per-container stats, and the axum
|
||||||
operator dashboard (`dashboard.rs`). Largest crate.
|
operator dashboard (`dashboard.rs`). Largest crate.
|
||||||
- **`hive-ag3nt/`** — in-container harness; three sibling binaries for
|
- **`hive-ag3nt/`** — in-container harness; one `hive` binary for every
|
||||||
every agent (`hive-agent` serve loop, `hive-agent-mcp`,
|
agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
||||||
`hive-agent-wake`). Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
|
||||||
driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer
|
driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer
|
||||||
(`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config`
|
(`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config`
|
||||||
render), per-agent web UI (`web_ui/` module dir), event + turn-stats
|
render), per-agent web UI (`web_ui/` module dir), event + turn-stats
|
||||||
|
|
|
||||||
93
Cargo.lock
generated
93
Cargo.lock
generated
|
|
@ -630,16 +630,6 @@ version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-foundation"
|
|
||||||
version = "0.9.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -872,7 +862,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -935,15 +924,6 @@ version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encoding_rs"
|
|
||||||
version = "0.8.35"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -1077,27 +1057,6 @@ version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "forgejo-api"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4c51fffa047d5836a4d89179a20b21b7e41ad97e713104752169d819b83c9158"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"bytes",
|
|
||||||
"futures",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"soft_assert",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"url",
|
|
||||||
"urlencoding",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -1379,7 +1338,6 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
"forgejo-api",
|
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hive-claude",
|
"hive-claude",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
|
|
@ -1390,13 +1348,11 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1428,7 +1384,6 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap-markdown",
|
"clap-markdown",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"forgejo-api",
|
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"libc",
|
"libc",
|
||||||
"listenfd",
|
"listenfd",
|
||||||
|
|
@ -1443,7 +1398,6 @@ dependencies = [
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1463,12 +1417,9 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"forgejo-api",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
|
||||||
"url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1680,11 +1631,9 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3069,7 +3018,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -3082,7 +3030,6 @@ dependencies = [
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|
@ -3510,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
|
|
@ -3742,12 +3689,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "soft_assert"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5097ec7ea7218135541ad96348f1441d0c616537dd4ed9c47205920c35d7d97"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spki"
|
name = "spki"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
|
|
@ -3845,27 +3786,6 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"core-foundation 0.9.4",
|
|
||||||
"system-configuration-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration-sys"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
|
|
@ -4676,17 +4596,6 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-registry"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
"windows-result",
|
|
||||||
"windows-strings",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
|
||||||
|
|
@ -68,14 +68,6 @@ reqwest = { version = "0.12", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
] }
|
] }
|
||||||
forgejo-api = { version = "0.11", default-features = false, features = [
|
|
||||||
"rustls-tls",
|
|
||||||
] }
|
|
||||||
url = "2"
|
|
||||||
time = { version = "0.3", default-features = false, features = [
|
|
||||||
"formatting",
|
|
||||||
"parsing",
|
|
||||||
] }
|
|
||||||
petgraph = { version = "0.8", default-features = false, features = ["std"] }
|
petgraph = { version = "0.8", default-features = false, features = ["std"] }
|
||||||
matrix-sdk = { version = "0.14", default-features = false, features = [
|
matrix-sdk = { version = "0.14", default-features = false, features = [
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,7 @@ nspawn agent. Open questions, not yet wired:
|
||||||
|
|
||||||
## Harness systemd unit shape
|
## Harness systemd unit shape
|
||||||
|
|
||||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
|
One harness binary (`hive`), one `harness-base.nix` template, one
|
||||||
`hive-agent-wake` siblings), one `harness-base.nix` template, one
|
|
||||||
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
||||||
is no longer a separate manager service name or role distinction in
|
is no longer a separate manager service name or role distinction in
|
||||||
the harness — privilege differences live server-side in the broker
|
the harness — privilege differences live server-side in the broker
|
||||||
|
|
@ -260,8 +259,7 @@ bit set` regardless of `hyperhive.user.passwordlessSudo`.
|
||||||
|
|
||||||
### `serviceConfig` highlights
|
### `serviceConfig` highlights
|
||||||
|
|
||||||
- `ExecStart = pkgs.hyperhive/bin/hive-agent` — same binary for
|
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary.
|
||||||
every agent.
|
|
||||||
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
|
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
|
||||||
resilient across transient crashes without thundering retries.
|
resilient across transient crashes without thundering retries.
|
||||||
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
|
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,7 @@ Two things live in the `agent-configs` Forgejo organization:
|
||||||
The `hive-forge` CLI (separate workspace crate, see
|
The `hive-forge` CLI (separate workspace crate, see
|
||||||
[`README.md`](../README.md) file map) wraps the Forgejo REST API
|
[`README.md`](../README.md) file map) wraps the Forgejo REST API
|
||||||
with the per-agent token; agents call it for issue / PR / comment
|
with the per-agent token; agents call it for issue / PR / comment
|
||||||
ops as if it were a peer. All REST calls across the workspace
|
ops as if it were a peer.
|
||||||
(`hive-forge` verbs, hive-c0re provisioning, this poller) go through
|
|
||||||
the typed `forgejo-api` crate; only non-`/api/v1` web-router routes
|
|
||||||
(attachment / artifact downloads, log streaming) and the poller's
|
|
||||||
enrichment fetches of server-provided subject URLs stay on raw
|
|
||||||
reqwest.
|
|
||||||
|
|
||||||
## Notification poller (`hive-ag3nt/src/forge_notify.rs`)
|
## Notification poller (`hive-ag3nt/src/forge_notify.rs`)
|
||||||
|
|
||||||
|
|
@ -151,14 +146,11 @@ one path still marked-read directly (no read-before-comment value).
|
||||||
|
|
||||||
The poller starts disabled and stays that way for any of:
|
The poller starts disabled and stays that way for any of:
|
||||||
|
|
||||||
- `HIVE_FORGE_URL` not set (no forge configured for this hive), or
|
- `HIVE_FORGE_URL` not set (no forge configured for this hive).
|
||||||
not parseable as a URL.
|
|
||||||
- `<state>/forge-token` missing or empty (agent has no forge
|
- `<state>/forge-token` missing or empty (agent has no forge
|
||||||
account — pre-provisioning or destroy-without-purge race).
|
account — pre-provisioning or destroy-without-purge race).
|
||||||
- Initial client construction fails (the typed `forgejo-api` client
|
- Initial `reqwest::Client::builder` fails (extremely unlikely;
|
||||||
for the API calls, or the plain reqwest client kept for the
|
treated as fatal-to-the-task only).
|
||||||
best-effort enrichment fetches of server-provided subject URLs;
|
|
||||||
both extremely unlikely; treated as fatal-to-the-task only).
|
|
||||||
|
|
||||||
Disabled = the spawned task returns immediately. All other failure
|
Disabled = the spawned task returns immediately. All other failure
|
||||||
modes (HTTP errors, parse errors, mark-read failures) are
|
modes (HTTP errors, parse errors, mark-read failures) are
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ claude has access to in return.
|
||||||
|
|
||||||
## The loop
|
## The loop
|
||||||
|
|
||||||
Each agent harness (`hive-agent` — one serve-loop binary for all
|
Each agent harness (`hive serve` — one binary for all agents) runs:
|
||||||
agents) runs:
|
|
||||||
|
|
||||||
1. Long-poll `Recv` on its socket. The host-side broker
|
1. Long-poll `Recv` on its socket. The host-side broker
|
||||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||||
|
|
@ -52,37 +51,38 @@ agents) runs:
|
||||||
|
|
||||||
## Harness binary shape
|
## Harness binary shape
|
||||||
|
|
||||||
Three sibling binaries out of the one `hive-ag3nt` crate, all
|
One `hive` binary for all agents. The earlier split into
|
||||||
role-agnostic. (The earlier split into `hive-ag3nt` + `hive-m1nd`
|
`hive-ag3nt` + `hive-m1nd` was collapsed because the privilege
|
||||||
was collapsed because the privilege boundary lives server-side at
|
boundary lives server-side at the broker socket
|
||||||
the broker socket (`/run/hive/mcp.sock`): `ManagerRequest` calls are
|
(`/run/hive/mcp.sock`): `ManagerRequest` calls are refused by the
|
||||||
refused by the standard agent socket regardless of who sends them.)
|
standard agent socket regardless of who sends them.
|
||||||
|
|
||||||
- `hive-agent` — long-running harness loop (the inbox poll +
|
Three subcommands:
|
||||||
|
|
||||||
|
- `serve` — long-running harness loop (the inbox poll +
|
||||||
claude-pump + ack/requeue cycle described above).
|
claude-pump + ack/requeue cycle described above).
|
||||||
- `hive-agent-mcp` — MCP server. Default: stdio child claude spawns
|
- `mcp` — MCP server. Default: stdio child claude spawns via
|
||||||
via `--mcp-config` per turn (the serve loop renders the config to
|
`--mcp-config` per turn. With `--http <addr>`, runs as a persistent
|
||||||
point at this sibling of its own `/proc/self/exe`). With
|
streamable-HTTP daemon instead (used by the `hive-mcp-http`
|
||||||
`--http <addr>`, runs as a persistent streamable-HTTP daemon
|
systemd unit when `hyperhive.mcp.httpPort` is set).
|
||||||
instead (used by the `hive-mcp-http` systemd unit when
|
- `wake --from <name> --body <body>` — push a message into our own
|
||||||
`hyperhive.mcp.httpPort` is set).
|
inbox so the next turn fires with the given body. Used by
|
||||||
- `hive-agent-wake --from <name> --body <body>` — push a message into
|
co-process daemons (matrix bridge, scraper, webhook listeners)
|
||||||
our own inbox so the next turn fires with the given body. Used by
|
to nudge claude on external events. `--body -` reads from stdin.
|
||||||
co-process helpers (scrapers, webhook listeners) to nudge claude on
|
|
||||||
external events. `--body -` reads from stdin.
|
|
||||||
|
|
||||||
### `Surface` trait + zero-sized type tags
|
### `Surface` trait + zero-sized type tags
|
||||||
|
|
||||||
`AgentRequest` / `AgentResponse` (= `ManagerRequest` / `ManagerResponse` —
|
`AgentRequest` / `AgentResponse` (= `ManagerRequest` / `ManagerResponse` —
|
||||||
type aliases) are the wire types. There is one role: agent.
|
type aliases) are the wire types. There is one role: agent.
|
||||||
`bin/hive-agent.rs` factors the turn loop through a `Surface` trait
|
`bin/hive.rs` factors the turn loop through a `Surface` trait with one
|
||||||
with one zero-sized impl (`AgentSurface`) wrapping:
|
zero-sized impl (`AgentSurface`) wrapping:
|
||||||
|
|
||||||
- One async method per wire op: `ack_turn`, `requeue_inflight`,
|
- One async method per wire op: `ack_turn`, `requeue_inflight`,
|
||||||
`inbox_unread`, `post_turn_counts`, `send_to_parent`, `recv_next`.
|
`inbox_unread`, `post_turn_counts`, `send_to_parent`,
|
||||||
|
`recv_next`, `wake_external`.
|
||||||
|
|
||||||
`main()` calls `serve_main::<AgentSurface>` for all roles. The turn
|
`main()` calls `serve_main::<AgentSurface>` for all roles. The turn
|
||||||
loop (`serve_loop` / `handle_turn`) has no per-role branches.
|
loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches.
|
||||||
|
|
||||||
### Boot wiring
|
### Boot wiring
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ External MCP servers (and any other in-container process) can
|
||||||
inject a wake-up event into the agent's inbox via the per-agent
|
inject a wake-up event into the agent's inbox via the per-agent
|
||||||
socket at `/run/hive/mcp.sock`. Two equivalent paths:
|
socket at `/run/hive/mcp.sock`. Two equivalent paths:
|
||||||
|
|
||||||
- **Shell out to `hive-agent-wake --from <label> --body <text>`**
|
- **Shell out to `hive wake --from <label> --body <text>`**
|
||||||
(use `--body -` to read body from stdin). Already on the
|
(use `--body -` to read body from stdin). Already on the
|
||||||
container's `PATH` since the harness binary is in
|
container's `PATH` since the harness binary is in
|
||||||
`systemPackages`. Convenient for shell-script integrations and
|
`systemPackages`. Convenient for shell-script integrations and
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
forgejo-api.workspace = true
|
|
||||||
url.workspace = true
|
|
||||||
time.workspace = true
|
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hive-claude.workspace = true
|
hive-claude.workspace = true
|
||||||
|
|
@ -31,8 +28,9 @@ tracing-subscriber.workspace = true
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
# Three sibling harness binaries for all agents (auto-discovered from
|
[[bin]]
|
||||||
# `src/bin/`): `hive-agent` (serve loop), `hive-agent-mcp` (MCP
|
# Unified harness binary for all agents. Privilege boundary is
|
||||||
# server), `hive-agent-wake` (wake CLI). Privilege boundary is
|
|
||||||
# enforced server-side at the socket (tool groups / manager surface).
|
# enforced server-side at the socket (tool groups / manager surface).
|
||||||
# See `docs/turn-loop.md::Harness binary shape`.
|
# See `docs/turn-loop.md::Harness binary shape`.
|
||||||
|
name = "hive"
|
||||||
|
path = "src/bin/hive.rs"
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
//! MCP-server binary. Default is stdio — spawned by `claude` via
|
|
||||||
//! `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
|
||||||
//! the hyperhive broker. Pass `--http <addr>` to instead run a long-lived
|
|
||||||
//! streamable-http listener (persistent daemon, the `hive-mcp-http`
|
|
||||||
//! systemd unit) that claude reconnects to each turn, avoiding the
|
|
||||||
//! per-turn stdio re-registration race. Sibling of `hive-agent` (the
|
|
||||||
//! serve loop that renders the `--mcp-config` blob pointing here) and
|
|
||||||
//! `hive-agent-wake`.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use clap::Parser;
|
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, mcp};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(name = "hive-agent-mcp", about = "hyperhive MCP server")]
|
|
||||||
struct Cli {
|
|
||||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
|
||||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
|
||||||
socket: PathBuf,
|
|
||||||
|
|
||||||
/// Serve over streamable-http on this address (e.g.
|
|
||||||
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
|
|
||||||
#[arg(long)]
|
|
||||||
http: Option<std::net::SocketAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
|
||||||
match cli.http {
|
|
||||||
Some(addr) => mcp::serve_http(cli.socket, addr).await,
|
|
||||||
None => mcp::serve_agent_stdio(cli.socket).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
//! Wake CLI: inject a wake-up event into this container's harness inbox
|
|
||||||
//! so the next turn fires with the given body. Intended for extra MCP
|
|
||||||
//! servers / helpers (scraper, webhook listener, etc.) that need to
|
|
||||||
//! nudge claude on external events; the built-in daemons (matrix, bash)
|
|
||||||
//! talk to the socket directly instead. Sibling of `hive-agent` and
|
|
||||||
//! `hive-agent-mcp`.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use clap::Parser;
|
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, client};
|
|
||||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
|
|
||||||
struct Cli {
|
|
||||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
|
||||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
|
||||||
socket: PathBuf,
|
|
||||||
|
|
||||||
#[arg(long)]
|
|
||||||
from: String,
|
|
||||||
|
|
||||||
/// Body of the wake message. Pass `-` to read from stdin.
|
|
||||||
#[arg(long)]
|
|
||||||
body: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
|
||||||
let body = if cli.body == "-" {
|
|
||||||
let mut buf = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
|
||||||
buf
|
|
||||||
} else {
|
|
||||||
cli.body
|
|
||||||
};
|
|
||||||
let resp: AgentResponse = client::request(
|
|
||||||
&cli.socket,
|
|
||||||
&AgentRequest::Wake {
|
|
||||||
from: cli.from,
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
match resp {
|
|
||||||
AgentResponse::Ok => Ok(()),
|
|
||||||
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
|
||||||
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
|
//! Unified hyperhive harness binary. Dispatches one of three subcommands
|
||||||
//! claude turn per message. There is one role: agent. The `Surface`
|
//! (`serve` / `mcp` / `wake`). There is one role: agent. The `Surface`
|
||||||
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
|
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
|
||||||
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
|
//! generic and testable. Architecture lives in
|
||||||
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
|
|
||||||
//! Architecture lives in
|
|
||||||
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -11,23 +9,58 @@ use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::{Parser, Subcommand};
|
||||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||||
use hive_ag3nt::login::{self, LoginState};
|
use hive_ag3nt::login::{self, LoginState};
|
||||||
use hive_ag3nt::turn_stats::TurnStats;
|
use hive_ag3nt::turn_stats::TurnStats;
|
||||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, plugins, serve_common, turn, web_ui};
|
use hive_ag3nt::{
|
||||||
|
DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui,
|
||||||
|
};
|
||||||
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
|
#[command(name = "hive", about = "hyperhive harness")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
|
|
||||||
/// Inbox poll interval in milliseconds.
|
#[command(subcommand)]
|
||||||
#[arg(long, default_value_t = 1000)]
|
cmd: Cmd,
|
||||||
poll_ms: u64,
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Run the long-lived harness loop. Polls inbox; replies via
|
||||||
|
/// `claude --print` when available.
|
||||||
|
Serve {
|
||||||
|
/// Inbox poll interval in milliseconds.
|
||||||
|
#[arg(long, default_value_t = 1000)]
|
||||||
|
poll_ms: u64,
|
||||||
|
},
|
||||||
|
/// Run the MCP server. Default is stdio — spawned by `claude` via
|
||||||
|
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back
|
||||||
|
/// into the hyperhive broker. Pass `--http <addr>` to instead run a
|
||||||
|
/// long-lived streamable-http listener (persistent daemon) that
|
||||||
|
/// claude reconnects to each turn, avoiding the per-turn stdio
|
||||||
|
/// re-registration race.
|
||||||
|
Mcp {
|
||||||
|
/// Serve over streamable-http on this address (e.g.
|
||||||
|
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
|
||||||
|
#[arg(long)]
|
||||||
|
http: Option<std::net::SocketAddr>,
|
||||||
|
},
|
||||||
|
/// Inject a wake-up event into this harness's inbox so the next
|
||||||
|
/// turn fires with the given body. Intended for extra MCP servers
|
||||||
|
/// / helpers (matrix bridge, scraper, webhook listener, etc.) that
|
||||||
|
/// need to nudge claude on external events.
|
||||||
|
Wake {
|
||||||
|
#[arg(long)]
|
||||||
|
from: String,
|
||||||
|
/// Body of the wake message. Pass `-` to read from stdin.
|
||||||
|
#[arg(long)]
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -40,7 +73,15 @@ async fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
|
|
||||||
|
match cli.cmd {
|
||||||
|
Cmd::Serve { poll_ms } => serve_main::<AgentSurface>(&cli.socket, poll_ms).await,
|
||||||
|
Cmd::Mcp { http } => match http {
|
||||||
|
Some(addr) => mcp::serve_http(cli.socket, addr).await,
|
||||||
|
None => mcp::serve_agent_stdio(cli.socket).await,
|
||||||
|
},
|
||||||
|
Cmd::Wake { from, body } => wake::<AgentSurface>(&cli.socket, from, body).await,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- shared turn helpers ----------
|
// ---------- shared turn helpers ----------
|
||||||
|
|
@ -220,6 +261,13 @@ trait Surface {
|
||||||
/// generic `serve_loop` doesn't need the per-role Response enum
|
/// generic `serve_loop` doesn't need the per-role Response enum
|
||||||
/// at all.
|
/// at all.
|
||||||
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
|
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
|
||||||
|
|
||||||
|
/// External `wake` subcommand (the `hive wake` CLI command, used
|
||||||
|
/// by co-process daemons like matrix to push events into the
|
||||||
|
/// harness inbox). Errors out via `anyhow::bail!` so the calling
|
||||||
|
/// binary surfaces them on stderr.
|
||||||
|
fn wake_external(socket: &Path, from: String, body: String)
|
||||||
|
-> impl Future<Output = Result<()>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- AgentSurface ----------
|
// ---------- AgentSurface ----------
|
||||||
|
|
@ -335,6 +383,16 @@ impl Surface for AgentSurface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wake_external(socket: &Path, from: String, body: String) -> Result<()> {
|
||||||
|
let resp: AgentResponse =
|
||||||
|
client::request(socket, &AgentRequest::Wake { from, body }).await?;
|
||||||
|
match resp {
|
||||||
|
AgentResponse::Ok => Ok(()),
|
||||||
|
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
||||||
|
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- generic turn loop ----------
|
// ---------- generic turn loop ----------
|
||||||
|
|
@ -615,6 +673,20 @@ async fn handle_turn<S: Surface>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// External `hive wake` subcommand — push a message into our own
|
||||||
|
/// inbox so the next turn fires with the given body. Reads the body
|
||||||
|
/// from stdin when `body == "-"`.
|
||||||
|
async fn wake<S: Surface>(socket: &Path, from: String, body: String) -> Result<()> {
|
||||||
|
let body = if body == "-" {
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||||
|
buf
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
};
|
||||||
|
S::wake_external(socket, from, body).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod continue_tests {
|
mod continue_tests {
|
||||||
use super::{TurnControl, should_self_continue, synthetic_continue};
|
use super::{TurnControl, should_self_continue, synthetic_continue};
|
||||||
|
|
@ -24,18 +24,9 @@ use std::fmt::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use forgejo_api::structs::{
|
|
||||||
NotificationThread, NotifyGetListQuery, NotifyReadThreadQuery, NotifySubjectType,
|
|
||||||
};
|
|
||||||
use forgejo_api::{Auth, Forgejo, ForgejoError};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const POLL_INTERVAL_SECS: u64 = 30;
|
const POLL_INTERVAL_SECS: u64 = 30;
|
||||||
/// Per-request cap applied to every forge call — natively on the reqwest
|
|
||||||
/// enrichment client, via `tokio::time::timeout` around the typed
|
|
||||||
/// `forgejo-api` client (which exposes no timeout knob of its own).
|
|
||||||
const HTTP_TIMEOUT_SECS: u64 = 10;
|
const HTTP_TIMEOUT_SECS: u64 = 10;
|
||||||
/// Page size of the unread-notifications fetch. This is also the hard
|
/// Page size of the unread-notifications fetch. This is also the hard
|
||||||
/// bound on the persisted delivery-dedupe cursor: each poll prunes the
|
/// bound on the persisted delivery-dedupe cursor: each poll prunes the
|
||||||
|
|
@ -61,7 +52,7 @@ const TOKEN_RETRY_MAX: u32 = 20;
|
||||||
/// claim it's "new" — see docs/forge.md, "new vs activity on".
|
/// claim it's "new" — see docs/forge.md, "new vs activity on".
|
||||||
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
|
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
|
||||||
|
|
||||||
/// Spawn point: called once from the `hive-agent` serve loop. Returns immediately if the forge is not
|
/// Spawn point: called once from `hive serve`. Returns immediately if the forge is not
|
||||||
/// configured. Otherwise loops forever, polling every
|
/// configured. Otherwise loops forever, polling every
|
||||||
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
||||||
///
|
///
|
||||||
|
|
@ -106,27 +97,6 @@ pub async fn run(socket: PathBuf) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typed Forgejo client for the API calls with stable shapes (identity
|
|
||||||
// probe, notification list, mark-read). The plain reqwest client below
|
|
||||||
// stays for the best-effort enrichment fetches of `subject.url` /
|
|
||||||
// `latest_comment_url`: those follow server-provided URLs whose payload
|
|
||||||
// shape is heterogeneous (issue vs comment vs review), which the typed
|
|
||||||
// client cannot express (its `Endpoint` trait is sealed).
|
|
||||||
let base_url = match url::Url::parse(&forge_url) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("forge_notify: invalid HIVE_FORGE_URL {forge_url}: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let forge = match Forgejo::new(Auth::Token(&token), base_url) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("forge_notify: failed to build forge client: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -141,15 +111,13 @@ pub async fn run(socket: PathBuf) {
|
||||||
// Fetch own login once for self-notification filtering. Falls back
|
// Fetch own login once for self-notification filtering. Falls back
|
||||||
// to empty string on failure — no filtering (safe degradation; see
|
// to empty string on failure — no filtering (safe degradation; see
|
||||||
// `docs/forge.md::Self-notification filtering`).
|
// `docs/forge.md::Self-notification filtering`).
|
||||||
let own_login = tokio::time::timeout(
|
let own_login = {
|
||||||
Duration::from_secs(HTTP_TIMEOUT_SECS),
|
let url = format!("{forge_url}/api/v1/user");
|
||||||
forge.user_get_current().send(),
|
fetch_json(&client, &url, &token)
|
||||||
)
|
.await
|
||||||
.await
|
.and_then(|v| v["login"].as_str().map(std::borrow::ToOwned::to_owned))
|
||||||
.ok()
|
.unwrap_or_default()
|
||||||
.and_then(Result::ok)
|
};
|
||||||
.and_then(|u| u.login)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if own_login.is_empty() {
|
if own_login.is_empty() {
|
||||||
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
|
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -192,7 +160,15 @@ pub async fn run(socket: PathBuf) {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
poll_once(&forge, &client, &token, &socket, &mut delivered, &own_login).await;
|
poll_once(
|
||||||
|
&client,
|
||||||
|
&forge_url,
|
||||||
|
&token,
|
||||||
|
&socket,
|
||||||
|
&mut delivered,
|
||||||
|
&own_login,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,16 +188,14 @@ async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option<
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a Forgejo notification `subject.type` to a human-readable label.
|
/// Map a Forgejo notification `subject.type` to a human-readable label.
|
||||||
/// `Commit` / `Repository` keep their API names, matching the old raw
|
/// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown
|
||||||
/// pass-through of types we don't relabel; a missing type degrades to
|
/// type is passed through as-is so new Forgejo types degrade gracefully
|
||||||
/// `?` like every other absent field.
|
/// rather than silently collapsing into a generic label.
|
||||||
fn notif_type_label(t: Option<NotifySubjectType>) -> &'static str {
|
fn notif_type_label(t: &str) -> &str {
|
||||||
match t {
|
match t {
|
||||||
Some(NotifySubjectType::Pull) => "PR",
|
"Pull" => "PR",
|
||||||
Some(NotifySubjectType::Issue) => "issue",
|
"Issue" => "issue",
|
||||||
Some(NotifySubjectType::Commit) => "Commit",
|
other => other,
|
||||||
Some(NotifySubjectType::Repository) => "Repository",
|
|
||||||
None => "?",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,19 +349,14 @@ fn review_state_label(state: &str) -> Option<&str> {
|
||||||
async fn format_notification(
|
async fn format_notification(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
token: &str,
|
token: &str,
|
||||||
notif: &PolledNotification,
|
notif: &serde_json::Value,
|
||||||
own_login: &str,
|
own_login: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let subj = notif.thread.subject.as_ref();
|
let title = notif["subject"]["title"].as_str().unwrap_or("?");
|
||||||
let title = subj.and_then(|s| s.title.as_deref()).unwrap_or("?");
|
let notif_type = notif["subject"]["type"].as_str().unwrap_or("?");
|
||||||
let subject_type = subj.and_then(|s| s.r#type);
|
let html_url = notif["subject"]["html_url"]
|
||||||
// forgejo-api maps a blank `html_url` (Go marshals empty strings) to
|
.as_str()
|
||||||
// `None`, so the API-url fallback also covers present-but-empty —
|
.unwrap_or_else(|| notif["subject"]["url"].as_str().unwrap_or(""));
|
||||||
// deliberate: a fetchable API link beats the raw-HTTP predecessor's
|
|
||||||
// empty `url:` line.
|
|
||||||
let html_url = subj
|
|
||||||
.and_then(|s| s.html_url.as_ref().or(s.url.as_ref()))
|
|
||||||
.map_or("", url::Url::as_str);
|
|
||||||
|
|
||||||
// Extract issue/PR number from the html_url. URL ends with /issues/N or
|
// Extract issue/PR number from the html_url. URL ends with /issues/N or
|
||||||
// /pulls/N (possibly followed by #anchor for comments). Best-effort.
|
// /pulls/N (possibly followed by #anchor for comments). Best-effort.
|
||||||
|
|
@ -400,24 +369,19 @@ async fn format_notification(
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Repo slug for multi-repo disambiguation. Falls back gracefully when absent.
|
// Repo slug for multi-repo disambiguation. Falls back gracefully when absent.
|
||||||
let repo = notif
|
let repo = notif["repository"]["full_name"]
|
||||||
.thread
|
.as_str()
|
||||||
.repository
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|r| r.full_name.as_deref())
|
|
||||||
.map(|r| format!(" {r}"))
|
.map(|r| format!(" {r}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// API URLs for fetching content
|
// API URLs for fetching content
|
||||||
let subject_api_url = subj
|
let subject_api_url = notif["subject"]["url"].as_str().unwrap_or("");
|
||||||
.and_then(|s| s.url.as_ref())
|
let comment_api_url = notif["subject"]["latest_comment_url"]
|
||||||
.map_or("", url::Url::as_str);
|
.as_str()
|
||||||
let comment_api_url = subj
|
.unwrap_or("");
|
||||||
.and_then(|s| s.latest_comment_url.as_ref())
|
let comment_html_url = notif["subject"]["latest_comment_html_url"]
|
||||||
.map_or("", url::Url::as_str);
|
.as_str()
|
||||||
let comment_html_url = subj
|
.unwrap_or("");
|
||||||
.and_then(|s| s.latest_comment_html_url.as_ref())
|
|
||||||
.map_or("", url::Url::as_str);
|
|
||||||
|
|
||||||
// Always fetch subject detail for assignee/reviewer metadata so
|
// Always fetch subject detail for assignee/reviewer metadata so
|
||||||
// the meta suffix can show current ownership without a follow-up
|
// the meta suffix can show current ownership without a follow-up
|
||||||
|
|
@ -430,7 +394,7 @@ async fn format_notification(
|
||||||
|
|
||||||
// Forgejo's notification `subject.type` is "Pull" / "Issue", never
|
// Forgejo's notification `subject.type` is "Pull" / "Issue", never
|
||||||
// "Pull Request".
|
// "Pull Request".
|
||||||
let is_pr = subject_type == Some(NotifySubjectType::Pull);
|
let is_pr = notif_type == "Pull";
|
||||||
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
|
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
|
||||||
|
|
||||||
// Determine whether this notification was triggered by a comment/review or
|
// Determine whether this notification was triggered by a comment/review or
|
||||||
|
|
@ -439,7 +403,7 @@ async fn format_notification(
|
||||||
|
|
||||||
let meta = NotifMeta {
|
let meta = NotifMeta {
|
||||||
title,
|
title,
|
||||||
subject_type,
|
notif_type,
|
||||||
html_url,
|
html_url,
|
||||||
num,
|
num,
|
||||||
repo,
|
repo,
|
||||||
|
|
@ -458,14 +422,14 @@ async fn format_notification(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
format_state_change_notification(notif.thread.updated_at, ¬if.state, &meta, own_login)
|
format_state_change_notification(notif, &meta, own_login)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared notification metadata extracted from the polled notification.
|
/// Shared notification metadata extracted from the raw Forgejo JSON.
|
||||||
struct NotifMeta<'a> {
|
struct NotifMeta<'a> {
|
||||||
title: &'a str,
|
title: &'a str,
|
||||||
subject_type: Option<NotifySubjectType>,
|
notif_type: &'a str,
|
||||||
html_url: &'a str,
|
html_url: &'a str,
|
||||||
num: String,
|
num: String,
|
||||||
repo: String,
|
repo: String,
|
||||||
|
|
@ -558,7 +522,7 @@ async fn format_comment_notification(
|
||||||
};
|
};
|
||||||
let NotifMeta {
|
let NotifMeta {
|
||||||
title,
|
title,
|
||||||
subject_type,
|
notif_type,
|
||||||
num,
|
num,
|
||||||
repo,
|
repo,
|
||||||
meta_suffix,
|
meta_suffix,
|
||||||
|
|
@ -583,7 +547,7 @@ async fn format_comment_notification(
|
||||||
Some(out)
|
Some(out)
|
||||||
} else {
|
} else {
|
||||||
// Regular comment.
|
// Regular comment.
|
||||||
let kind = format!("comment on {}{num}{repo}", notif_type_label(*subject_type));
|
let kind = format!("comment on {}{num}{repo}", notif_type_label(notif_type));
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}"
|
"[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}"
|
||||||
);
|
);
|
||||||
|
|
@ -599,17 +563,17 @@ async fn format_comment_notification(
|
||||||
/// path applies. Only creations are dropped: a later state change on the
|
/// path applies. Only creations are dropped: a later state change on the
|
||||||
/// agent's own subject is driven by someone else and stays a wake.
|
/// agent's own subject is driven by someone else and stays a wake.
|
||||||
fn format_state_change_notification(
|
fn format_state_change_notification(
|
||||||
event_time: Option<OffsetDateTime>,
|
notif: &serde_json::Value,
|
||||||
notif_state: &str,
|
|
||||||
meta: &NotifMeta<'_>,
|
meta: &NotifMeta<'_>,
|
||||||
own_login: &str,
|
own_login: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Classification uses the raw `subject.state` string extracted in
|
// Classification uses notif["subject"]["state"] directly — Forgejo
|
||||||
// `parse_notification` — Forgejo returns "open" / "closed" / "merged"
|
// returns "open" / "closed" / "merged" here. We do NOT rely on
|
||||||
// there. We do NOT rely on fetching the PR/issue detail for `merged`:
|
// fetching the PR/issue detail for `merged`:
|
||||||
// - `subject.url` points to the *issues* endpoint, which returns
|
// - `subject.url` points to the *issues* endpoint, which returns
|
||||||
// `pull_request.merged`, not top-level `merged`.
|
// `pull_request.merged`, not top-level `merged`.
|
||||||
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
|
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
|
||||||
|
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
|
||||||
|
|
||||||
// "New" = the subject is open (or state is absent). Used below for
|
// "New" = the subject is open (or state is absent). Used below for
|
||||||
// the review-request override.
|
// the review-request override.
|
||||||
|
|
@ -617,7 +581,7 @@ fn format_state_change_notification(
|
||||||
|
|
||||||
let NotifMeta {
|
let NotifMeta {
|
||||||
title,
|
title,
|
||||||
subject_type,
|
notif_type,
|
||||||
html_url,
|
html_url,
|
||||||
num,
|
num,
|
||||||
repo,
|
repo,
|
||||||
|
|
@ -625,7 +589,7 @@ fn format_state_change_notification(
|
||||||
subject,
|
subject,
|
||||||
is_pr,
|
is_pr,
|
||||||
} = meta;
|
} = meta;
|
||||||
let label = notif_type_label(*subject_type);
|
let label = notif_type_label(notif_type);
|
||||||
// Only claim "new" when the notification actually fired at creation
|
// Only claim "new" when the notification actually fired at creation
|
||||||
// time. A review submitted with no body carries no
|
// time. A review submitted with no body carries no
|
||||||
// `latest_comment_url`, so it lands here instead of on the comment
|
// `latest_comment_url`, so it lands here instead of on the comment
|
||||||
|
|
@ -634,7 +598,7 @@ fn format_state_change_notification(
|
||||||
// on"): agents dismiss it as a
|
// on"): agents dismiss it as a
|
||||||
// duplicate of the original open notification. When we can't confirm
|
// duplicate of the original open notification. When we can't confirm
|
||||||
// creation, fall back to a neutral "activity on" label.
|
// creation, fall back to a neutral "activity on" label.
|
||||||
let looks_new = notification_is_creation(event_time, subject.as_ref());
|
let looks_new = notification_is_creation(notif, subject.as_ref());
|
||||||
|
|
||||||
// Self-authored creation filter: skip an agent being woken by its own
|
// Self-authored creation filter: skip an agent being woken by its own
|
||||||
// freshly-opened issue/PR. The subject payload is already fetched (for
|
// freshly-opened issue/PR. The subject payload is already fetched (for
|
||||||
|
|
@ -704,66 +668,81 @@ fn format_state_change_notification(
|
||||||
/// new item behind the neutral fallback. See docs/forge.md, "new vs
|
/// new item behind the neutral fallback. See docs/forge.md, "new vs
|
||||||
/// activity on".
|
/// activity on".
|
||||||
fn notification_is_creation(
|
fn notification_is_creation(
|
||||||
event: Option<OffsetDateTime>,
|
notif: &serde_json::Value,
|
||||||
subject: Option<&serde_json::Value>,
|
subject: Option<&serde_json::Value>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let created = subject
|
let created = subject
|
||||||
.and_then(|s| s["created_at"].as_str())
|
.and_then(|s| s["created_at"].as_str())
|
||||||
.and_then(parse_rfc3339);
|
.and_then(parse_rfc3339_secs);
|
||||||
|
let event = notif["updated_at"].as_str().and_then(parse_rfc3339_secs);
|
||||||
match (created, event) {
|
match (created, event) {
|
||||||
(Some(c), Some(e)) => (e - c).whole_seconds().abs() <= NEW_ITEM_TOLERANCE_SECS,
|
(Some(c), Some(e)) => (e - c).abs() <= NEW_ITEM_TOLERANCE_SECS,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an RFC 3339 timestamp as Forgejo emits them
|
/// Minimal dependency-free RFC 3339 / ISO 8601 parser → Unix epoch
|
||||||
/// (`2026-06-13T11:18:42+02:00` or `...Z`, optionally with fractional
|
/// seconds. Forgejo emits timestamps like `2026-06-13T11:18:42+02:00`
|
||||||
/// seconds). Returns `None` on any shape `time` doesn't recognise so
|
/// or `...Z`, optionally with fractional seconds. We only need
|
||||||
/// callers can fall back gracefully.
|
/// second-granularity comparison, so the fractional part is skipped.
|
||||||
fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
|
/// Returns `None` on any shape we don't recognise so callers can fall
|
||||||
OffsetDateTime::parse(s, &Rfc3339).ok()
|
/// back gracefully.
|
||||||
}
|
fn parse_rfc3339_secs(s: &str) -> Option<i64> {
|
||||||
|
if s.len() < 19 {
|
||||||
/// One notification from the poll page: the typed thread plus two raw
|
return None;
|
||||||
/// fields the typed structs can't carry faithfully.
|
|
||||||
struct PolledNotification {
|
|
||||||
thread: NotificationThread,
|
|
||||||
/// Raw `subject.state`. Forgejo reports `"merged"` for merged PRs
|
|
||||||
/// (`services/convert/notification.go`), which forgejo-api's
|
|
||||||
/// `StateType` (open/closed only) rejects at deserialization — so
|
|
||||||
/// the state is extracted verbatim before the typed parse and
|
|
||||||
/// matched as a string, exactly like the pre-typed code.
|
|
||||||
state: String,
|
|
||||||
/// Raw `updated_at` string, byte-identical to what Forgejo sent, so
|
|
||||||
/// the persisted delivery-dedupe cursor keeps its exact format
|
|
||||||
/// across the typed-client port (no spurious re-deliveries from a
|
|
||||||
/// reformatting round-trip).
|
|
||||||
updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one notification JSON object: pull out the raw `subject.state`
|
|
||||||
/// and `updated_at` (see [`PolledNotification`]), null the state so the
|
|
||||||
/// closed `StateType` enum can't reject it, and deserialize the rest
|
|
||||||
/// into the typed [`NotificationThread`]. Returns `None` (with a warn)
|
|
||||||
/// for an item the typed struct can't represent — the rest of the page
|
|
||||||
/// still delivers.
|
|
||||||
fn parse_notification(mut value: serde_json::Value) -> Option<PolledNotification> {
|
|
||||||
let state = value["subject"]["state"].as_str().unwrap_or("").to_owned();
|
|
||||||
if let Some(s) = value.get_mut("subject").and_then(|s| s.get_mut("state")) {
|
|
||||||
*s = serde_json::Value::Null;
|
|
||||||
}
|
}
|
||||||
let updated_at = value["updated_at"].as_str().unwrap_or("").to_owned();
|
let year: i64 = s.get(0..4)?.parse().ok()?;
|
||||||
match serde_json::from_value::<NotificationThread>(value) {
|
let month: i64 = s.get(5..7)?.parse().ok()?;
|
||||||
Ok(thread) => Some(PolledNotification {
|
let day: i64 = s.get(8..10)?.parse().ok()?;
|
||||||
thread,
|
let hour: i64 = s.get(11..13)?.parse().ok()?;
|
||||||
state,
|
let minute: i64 = s.get(14..16)?.parse().ok()?;
|
||||||
updated_at,
|
let second: i64 = s.get(17..19)?.parse().ok()?;
|
||||||
}),
|
|
||||||
Err(e) => {
|
let mut epoch =
|
||||||
warn!("forge_notify: notification parse error: {e}");
|
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second;
|
||||||
None
|
|
||||||
|
// Remainder after seconds: optional `.fff` fraction, then a zone.
|
||||||
|
let mut rest = &s[19..];
|
||||||
|
if let Some(frac) = rest.strip_prefix('.') {
|
||||||
|
let end = frac
|
||||||
|
.find(|c: char| !c.is_ascii_digit())
|
||||||
|
.unwrap_or(frac.len());
|
||||||
|
rest = &frac[end..];
|
||||||
|
}
|
||||||
|
// Zone: `Z`/empty = UTC; otherwise `±HH:MM`. Subtract the offset to
|
||||||
|
// normalise to UTC epoch seconds.
|
||||||
|
if !(rest.is_empty() || rest.starts_with('Z')) {
|
||||||
|
let sign = rest.as_bytes()[0];
|
||||||
|
let off = &rest[1..];
|
||||||
|
// Accept `HH:MM` (Forgejo's form) and bare `HHMM`; minutes optional.
|
||||||
|
// Both fields fail the same way — a present-but-unparseable component
|
||||||
|
// returns `None` rather than one silently defaulting.
|
||||||
|
let (hh, mm) = match off.split_once(':') {
|
||||||
|
Some((h, m)) => (h, m),
|
||||||
|
None => (off.get(0..2)?, off.get(2..4).unwrap_or("00")),
|
||||||
|
};
|
||||||
|
let oh: i64 = hh.parse().ok()?;
|
||||||
|
let om: i64 = if mm.is_empty() { 0 } else { mm.parse().ok()? };
|
||||||
|
let offset = oh * 3_600 + om * 60;
|
||||||
|
match sign {
|
||||||
|
b'+' => epoch -= offset,
|
||||||
|
b'-' => epoch += offset,
|
||||||
|
_ => return None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the Unix epoch for a proleptic-Gregorian `y-m-d`
|
||||||
|
/// (Howard Hinnant's `days_from_civil`). Valid for all dates Forgejo
|
||||||
|
/// can emit.
|
||||||
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
era * 146_097 + doe - 719_468
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
|
|
@ -773,45 +752,33 @@ fn parse_notification(mut value: serde_json::Value) -> Option<PolledNotification
|
||||||
functions for state shared across all three phases"
|
functions for state shared across all three phases"
|
||||||
)]
|
)]
|
||||||
async fn poll_once(
|
async fn poll_once(
|
||||||
forge: &Forgejo,
|
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
forge_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
delivered: &mut HashMap<u64, String>,
|
delivered: &mut HashMap<u64, String>,
|
||||||
own_login: &str,
|
own_login: &str,
|
||||||
) {
|
) {
|
||||||
// Fetch the page as raw JSON (`response_type::<String>`) instead of the
|
let url = format!("{forge_url}/api/v1/notifications?all=false&limit={UNREAD_FETCH_LIMIT}");
|
||||||
// crate's `Vec<NotificationThread>`: one merged-PR notification
|
let resp = match client
|
||||||
// (`subject.state = "merged"`, unrepresentable in `StateType`) would
|
.get(&url)
|
||||||
// otherwise poison deserialization of the whole page. HTTP-level errors
|
.header("Authorization", format!("token {token}"))
|
||||||
// still surface as `ForgejoError` exactly like the fully-typed call;
|
.send()
|
||||||
// `parse_notification` below does the per-item typed parse.
|
.await
|
||||||
let query = NotifyGetListQuery {
|
{
|
||||||
all: Some(false),
|
Ok(r) => r,
|
||||||
..NotifyGetListQuery::default()
|
Err(e) => {
|
||||||
|
debug!("forge_notify: poll request failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let request = forge
|
|
||||||
.notify_get_list(query)
|
|
||||||
.page_size(u32::try_from(UNREAD_FETCH_LIMIT).unwrap_or(u32::MAX))
|
|
||||||
.response_type::<String>();
|
|
||||||
let raw =
|
|
||||||
match tokio::time::timeout(Duration::from_secs(HTTP_TIMEOUT_SECS), request.send()).await {
|
|
||||||
Ok(Ok(raw)) => raw,
|
|
||||||
Ok(Err(ForgejoError::UnexpectedStatusCode(status))) => {
|
|
||||||
debug!("forge_notify: poll status {status}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
debug!("forge_notify: poll request failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
debug!("forge_notify: poll request failed: timed out");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let values: Vec<serde_json::Value> = match serde_json::from_str(&raw) {
|
if !resp.status().is_success() {
|
||||||
|
debug!("forge_notify: poll status {}", resp.status());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notifications: Vec<serde_json::Value> = match resp.json().await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("forge_notify: response parse error: {e}");
|
warn!("forge_notify: response parse error: {e}");
|
||||||
|
|
@ -819,25 +786,22 @@ async fn poll_once(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if values.is_empty() {
|
if notifications.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
count = values.len(),
|
count = notifications.len(),
|
||||||
"forge_notify: delivering notifications"
|
"forge_notify: delivering notifications"
|
||||||
);
|
);
|
||||||
|
|
||||||
let notifications: Vec<PolledNotification> =
|
|
||||||
values.into_iter().filter_map(parse_notification).collect();
|
|
||||||
|
|
||||||
// Tracks whether the dedupe cursor changed this poll (a new delivery
|
// Tracks whether the dedupe cursor changed this poll (a new delivery
|
||||||
// recorded, or the prune below dropped now-read threads) so we only
|
// recorded, or the prune below dropped now-read threads) so we only
|
||||||
// rewrite the on-disk cursor when there's something to persist.
|
// rewrite the on-disk cursor when there's something to persist.
|
||||||
let mut cursor_dirty = false;
|
let mut cursor_dirty = false;
|
||||||
|
|
||||||
for notif in ¬ifications {
|
for notif in ¬ifications {
|
||||||
let Some(id) = notif.thread.id.and_then(|id| u64::try_from(id).ok()) else {
|
let Some(id) = notif["id"].as_u64() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -846,7 +810,7 @@ async fn poll_once(
|
||||||
// Skip it silently unless its `updated_at` advanced since the
|
// Skip it silently unless its `updated_at` advanced since the
|
||||||
// version we last delivered a wake for (i.e. genuinely new
|
// version we last delivered a wake for (i.e. genuinely new
|
||||||
// activity). See the `delivered` cursor note in `run`.
|
// activity). See the `delivered` cursor note in `run`.
|
||||||
let updated_at = notif.updated_at.clone();
|
let updated_at = notif["updated_at"].as_str().unwrap_or("").to_owned();
|
||||||
if !should_deliver(delivered, id, &updated_at) {
|
if !should_deliver(delivered, id, &updated_at) {
|
||||||
debug!(%id, "forge_notify: skipping (already delivered this version)");
|
debug!(%id, "forge_notify: skipping (already delivered this version)");
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -856,7 +820,7 @@ async fn poll_once(
|
||||||
|
|
||||||
// None means self-echo — mark read silently, no delivery.
|
// None means self-echo — mark read silently, no delivery.
|
||||||
let Some(body) = body_opt else {
|
let Some(body) = body_opt else {
|
||||||
mark_read(forge, id).await;
|
mark_read(client, forge_url, token, id).await;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -900,7 +864,7 @@ async fn poll_once(
|
||||||
// loud in tests/dev if a future pagination change silently breaks it.
|
// loud in tests/dev if a future pagination change silently breaks it.
|
||||||
let current_ids: HashSet<u64> = notifications
|
let current_ids: HashSet<u64> = notifications
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|n| n.thread.id.and_then(|id| u64::try_from(id).ok()))
|
.filter_map(|n| n["id"].as_u64())
|
||||||
.collect();
|
.collect();
|
||||||
let before_prune = delivered.len();
|
let before_prune = delivered.len();
|
||||||
delivered.retain(|id, _| current_ids.contains(id));
|
delivered.retain(|id, _| current_ids.contains(id));
|
||||||
|
|
@ -935,31 +899,21 @@ fn should_deliver(delivered: &HashMap<u64, String>, id: u64, updated_at: &str) -
|
||||||
/// deliberately left unread for the read-before-comment guard, and a failed
|
/// deliberately left unread for the read-before-comment guard, and a failed
|
||||||
/// delivery is left unread + out of the dedupe cursor so it resurfaces on the
|
/// delivery is left unread + out of the dedupe cursor so it resurfaces on the
|
||||||
/// next poll tick.
|
/// next poll tick.
|
||||||
async fn mark_read(forge: &Forgejo, id: u64) {
|
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
|
||||||
let Ok(thread_id) = i64::try_from(id) else {
|
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
|
||||||
// Thread ids originate from `i64` in the poll parse, so an
|
match client
|
||||||
// unrepresentable value can't actually reach here.
|
.patch(&mark_url)
|
||||||
return;
|
.header("Authorization", format!("token {token}"))
|
||||||
};
|
.send()
|
||||||
// `to_status: None` → Forgejo's default transition (unread → read),
|
.await
|
||||||
// matching the old bare PATCH. The 205 response body is the thread
|
{
|
||||||
// JSON, which can carry `subject.state = "merged"` — take it as an
|
Err(e) => {
|
||||||
// opaque `String` (see `poll_once`) so a merged PR doesn't turn a
|
|
||||||
// successful mark-read into a spurious parse error.
|
|
||||||
let request = forge
|
|
||||||
.notify_read_thread(thread_id, NotifyReadThreadQuery { to_status: None })
|
|
||||||
.response_type::<String>();
|
|
||||||
match tokio::time::timeout(Duration::from_secs(HTTP_TIMEOUT_SECS), request.send()).await {
|
|
||||||
Err(_) => {
|
|
||||||
warn!(%id, "forge_notify: mark-read request failed — notification will resurface");
|
|
||||||
}
|
|
||||||
Ok(Err(e @ ForgejoError::ReqwestError(_))) => {
|
|
||||||
warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface");
|
warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface");
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(r) if !r.status().is_success() => {
|
||||||
warn!(%id, error = %e, "forge_notify: mark-read returned non-2xx — notification will resurface");
|
warn!(%id, status = %r.status(), "forge_notify: mark-read returned non-2xx — notification will resurface");
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) => {
|
Ok(_) => {
|
||||||
debug!(%id, "forge_notify: marked read");
|
debug!(%id, "forge_notify: marked read");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1175,98 +1129,39 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_rfc3339_handles_offsets_and_z() {
|
fn parse_rfc3339_secs_handles_offsets_and_z() {
|
||||||
// Same instant expressed three ways must parse equal
|
// Same instant expressed three ways must parse equal.
|
||||||
// (`OffsetDateTime` comparison is instant-based).
|
let utc = parse_rfc3339_secs("2026-06-13T09:18:42Z").unwrap();
|
||||||
let utc = parse_rfc3339("2026-06-13T09:18:42Z").unwrap();
|
let plus2 = parse_rfc3339_secs("2026-06-13T11:18:42+02:00").unwrap();
|
||||||
let plus2 = parse_rfc3339("2026-06-13T11:18:42+02:00").unwrap();
|
let minus5 = parse_rfc3339_secs("2026-06-13T04:18:42-05:00").unwrap();
|
||||||
let minus5 = parse_rfc3339("2026-06-13T04:18:42-05:00").unwrap();
|
|
||||||
assert_eq!(utc, plus2);
|
assert_eq!(utc, plus2);
|
||||||
assert_eq!(utc, minus5);
|
assert_eq!(utc, minus5);
|
||||||
// Fractional seconds parse; second-granularity comparison holds.
|
// Fractional seconds are skipped (second granularity).
|
||||||
assert_eq!(
|
assert_eq!(parse_rfc3339_secs("2026-06-13T09:18:42.512Z").unwrap(), utc);
|
||||||
parse_rfc3339("2026-06-13T09:18:42.512Z")
|
|
||||||
.unwrap()
|
|
||||||
.unix_timestamp(),
|
|
||||||
utc.unix_timestamp(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_rfc3339_rejects_garbage() {
|
fn parse_rfc3339_secs_rejects_garbage() {
|
||||||
assert!(parse_rfc3339("").is_none());
|
assert!(parse_rfc3339_secs("").is_none());
|
||||||
assert!(parse_rfc3339("not-a-date").is_none());
|
assert!(parse_rfc3339_secs("not-a-date").is_none());
|
||||||
assert!(parse_rfc3339("2026-06-13").is_none());
|
assert!(parse_rfc3339_secs("2026-06-13").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notification_is_creation_flags_fresh_and_later_activity() {
|
fn notification_is_creation_flags_fresh_and_later_activity() {
|
||||||
let subject = serde_json::json!({ "created_at": "2026-06-13T11:18:40+02:00" });
|
|
||||||
|
|
||||||
// Fresh PR: notification event time == created_at → "new".
|
// Fresh PR: notification event time == created_at → "new".
|
||||||
let fresh = parse_rfc3339("2026-06-13T11:18:42+02:00");
|
let fresh = serde_json::json!({ "updated_at": "2026-06-13T11:18:42+02:00" });
|
||||||
assert!(notification_is_creation(fresh, Some(&subject)));
|
let subject = serde_json::json!({ "created_at": "2026-06-13T11:18:40+02:00" });
|
||||||
|
assert!(notification_is_creation(&fresh, Some(&subject)));
|
||||||
|
|
||||||
// Review hours later on the same PR → not a creation.
|
// Review hours later on the same PR → not a creation.
|
||||||
let later = parse_rfc3339("2026-06-13T14:55:00+02:00");
|
let later = serde_json::json!({ "updated_at": "2026-06-13T14:55:00+02:00" });
|
||||||
assert!(!notification_is_creation(later, Some(&subject)));
|
assert!(!notification_is_creation(&later, Some(&subject)));
|
||||||
|
|
||||||
// Missing timestamps → default to creation (preserve prior behavior).
|
// Missing timestamps → default to creation (preserve prior behavior).
|
||||||
assert!(notification_is_creation(None, None));
|
let empty = serde_json::json!({});
|
||||||
assert!(notification_is_creation(None, Some(&subject)));
|
assert!(notification_is_creation(&empty, None));
|
||||||
assert!(notification_is_creation(
|
assert!(notification_is_creation(&empty, Some(&subject)));
|
||||||
fresh,
|
|
||||||
Some(&serde_json::json!({}))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_notification_tolerates_merged_state() {
|
|
||||||
// forgejo-api's `StateType` has no "merged" variant; the raw
|
|
||||||
// extraction must keep the item parseable AND preserve the
|
|
||||||
// string for the `PR merged` wrapper.
|
|
||||||
let polled = parse_notification(serde_json::json!({
|
|
||||||
"id": 7,
|
|
||||||
"updated_at": "2026-06-13T11:18:42+02:00",
|
|
||||||
"url": "http://forge/api/v1/notifications/threads/7",
|
|
||||||
"subject": {
|
|
||||||
"title": "t",
|
|
||||||
"type": "Pull",
|
|
||||||
"state": "merged",
|
|
||||||
"html_url": "http://forge/o/r/pulls/5",
|
|
||||||
"latest_comment_html_url": "",
|
|
||||||
"latest_comment_url": "",
|
|
||||||
"url": "http://forge/api/v1/repos/o/r/issues/5",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
.expect("merged-state notification must parse");
|
|
||||||
assert_eq!(polled.state, "merged");
|
|
||||||
// Cursor string is the raw `updated_at`, byte-identical.
|
|
||||||
assert_eq!(polled.updated_at, "2026-06-13T11:18:42+02:00");
|
|
||||||
assert_eq!(polled.thread.id, Some(7));
|
|
||||||
assert_eq!(
|
|
||||||
polled.thread.subject.as_ref().and_then(|s| s.r#type),
|
|
||||||
Some(NotifySubjectType::Pull),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_notification_missing_subject_degrades() {
|
|
||||||
let polled = parse_notification(serde_json::json!({
|
|
||||||
"id": 1,
|
|
||||||
"updated_at": "2026-06-22T16:00:00Z",
|
|
||||||
"url": "",
|
|
||||||
}))
|
|
||||||
.expect("subject-less notification must parse");
|
|
||||||
assert_eq!(polled.state, "");
|
|
||||||
assert!(polled.thread.subject.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_notification_rejects_unrepresentable_item() {
|
|
||||||
// A non-object item can't become a `NotificationThread`; it is
|
|
||||||
// dropped alone instead of failing the page.
|
|
||||||
assert!(parse_notification(serde_json::json!("nonsense")).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `NotifMeta` for the state-change formatter tests. The `&str`
|
/// Build a `NotifMeta` for the state-change formatter tests. The `&str`
|
||||||
|
|
@ -1274,7 +1169,7 @@ mod tests {
|
||||||
fn state_change_meta(subject: serde_json::Value) -> NotifMeta<'static> {
|
fn state_change_meta(subject: serde_json::Value) -> NotifMeta<'static> {
|
||||||
NotifMeta {
|
NotifMeta {
|
||||||
title: "subject title",
|
title: "subject title",
|
||||||
subject_type: Some(NotifySubjectType::Issue),
|
notif_type: "Issue",
|
||||||
html_url: "http://forge/issues/1",
|
html_url: "http://forge/issues/1",
|
||||||
num: " #1".to_owned(),
|
num: " #1".to_owned(),
|
||||||
repo: " [agents/x]".to_owned(),
|
repo: " [agents/x]".to_owned(),
|
||||||
|
|
@ -1288,13 +1183,15 @@ mod tests {
|
||||||
fn state_change_drops_self_authored_creation() {
|
fn state_change_drops_self_authored_creation() {
|
||||||
// No timestamps ⇒ treated as a creation; poster login == own_login.
|
// No timestamps ⇒ treated as a creation; poster login == own_login.
|
||||||
let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } }));
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } }));
|
||||||
assert!(format_state_change_notification(None, "open", &meta, "damocles").is_none());
|
let notif = serde_json::json!({ "subject": { "state": "open" } });
|
||||||
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_change_keeps_other_authored_creation() {
|
fn state_change_keeps_other_authored_creation() {
|
||||||
let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } }));
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } }));
|
||||||
assert!(format_state_change_notification(None, "open", &meta, "damocles").is_some());
|
let notif = serde_json::json!({ "subject": { "state": "open" } });
|
||||||
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1306,7 +1203,10 @@ mod tests {
|
||||||
"user": { "login": "damocles" },
|
"user": { "login": "damocles" },
|
||||||
"created_at": "2020-01-01T00:00:00Z",
|
"created_at": "2020-01-01T00:00:00Z",
|
||||||
}));
|
}));
|
||||||
let event = parse_rfc3339("2026-06-22T16:00:00Z");
|
let notif = serde_json::json!({
|
||||||
assert!(format_state_change_notification(event, "closed", &meta, "damocles").is_some());
|
"subject": { "state": "closed" },
|
||||||
|
"updated_at": "2026-06-22T16:00:00Z",
|
||||||
|
});
|
||||||
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Shared in-container harness code for the sibling `hive-agent` /
|
//! Shared in-container harness code for the single `hive` binary that
|
||||||
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
|
//! serves every agent role (the manager is just an agent role, not a
|
||||||
//! agent role (the manager is just an agent role, not a separate set
|
//! separate binary).
|
||||||
//! of binaries).
|
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|
|
||||||
|
|
@ -330,13 +330,13 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// `mcp_binary` is the path (or PATH-resolvable name) of the
|
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
||||||
/// `hive-agent-mcp` bridge executable; `socket` is the hyperhive per-agent
|
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
||||||
/// socket bind-mounted into the container (forwarded to the child as
|
/// the container (forwarded to the child as `--socket <path>`). Merges in
|
||||||
/// `--socket <path>`). Merges in any extra MCP servers declared via
|
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
|
||||||
/// `hyperhive.extraMcpServers` in the agent's NixOS config.
|
/// agent's NixOS config.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_claude_config(mcp_binary: &str, socket: &std::path::Path) -> String {
|
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
||||||
let mut servers = serde_json::Map::new();
|
let mut servers = serde_json::Map::new();
|
||||||
// When the harness is configured to run the built-in server as a
|
// When the harness is configured to run the built-in server as a
|
||||||
// persistent streamable-http daemon (loopback port in
|
// persistent streamable-http daemon (loopback port in
|
||||||
|
|
@ -353,8 +353,8 @@ pub fn render_claude_config(mcp_binary: &str, socket: &std::path::Path) -> Strin
|
||||||
"url": format!("http://127.0.0.1:{port}/mcp"),
|
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||||
}),
|
}),
|
||||||
None => serde_json::json!({
|
None => serde_json::json!({
|
||||||
"command": mcp_binary,
|
"command": agent_binary,
|
||||||
"args": ["--socket", socket.display().to_string()],
|
"args": ["--socket", socket.display().to_string(), "mcp"],
|
||||||
"env": {}
|
"env": {}
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
//! Pure helpers factored out of the harness serve loop
|
//! Pure helpers factored out of the `hive` serve loop (`bin/hive.rs`).
|
||||||
//! (`bin/hive-agent.rs`).
|
|
||||||
//! Only functions with no wire-type dependency live here;
|
//! Only functions with no wire-type dependency live here;
|
||||||
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
|
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
|
||||||
//! stay in the binary because they touch the request enum variants directly.
|
//! stay in the binary because they touch the request enum variants directly.
|
||||||
|
|
@ -61,7 +60,7 @@ pub struct TurnRowArgs<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
|
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
|
||||||
/// (rather than inline in the serve loop) so it stays wire-type-free
|
/// (rather than inline in the `hive` serve loop) so it stays wire-type-free
|
||||||
/// and unit-testable; the binary just feeds it the post-turn counts.
|
/// and unit-testable; the binary just feeds it the post-turn counts.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow {
|
pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow {
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,8 @@ impl TurnFiles {
|
||||||
|
|
||||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||||
/// as `--socket <path>`). The MCP server is the `hive-agent-mcp` binary
|
/// as `--socket <path>`). The MCP subcommand is always `mcp` on the single
|
||||||
/// installed next to the running `hive-agent` (resolved as a sibling of
|
/// `hive` binary resolved from `/proc/self/exe`.
|
||||||
/// `/proc/self/exe`; PATH-resolvable name as the fallback).
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|
@ -121,8 +120,7 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
||||||
let path = parent.join("claude-mcp-config.json");
|
let path = parent.join("claude-mcp-config.json");
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| Some(p.parent()?.join("hive-agent-mcp")))
|
.map_or_else(|| "hive".into(), |p| p.display().to_string());
|
||||||
.map_or_else(|| "hive-agent-mcp".into(), |p| p.display().to_string());
|
|
||||||
let body = mcp_config::render_claude_config(&exe, socket);
|
let body = mcp_config::render_claude_config(&exe, socket);
|
||||||
tokio::fs::write(&path, body).await?;
|
tokio::fs::write(&path, body).await?;
|
||||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||||
|
|
@ -148,7 +146,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
|
||||||
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
||||||
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
||||||
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
||||||
/// `hive-agent` serve loop).
|
/// `hive` serve loop).
|
||||||
pub type TurnOutcome = std::result::Result<bool, TurnError>;
|
pub type TurnOutcome = std::result::Result<bool, TurnError>;
|
||||||
|
|
||||||
/// The ways a turn can end without a usable result. Each is deliberately *not*
|
/// The ways a turn can end without a usable result. Each is deliberately *not*
|
||||||
|
|
@ -289,7 +287,7 @@ pub fn make_session(bus: &Bus) -> AgentSession {
|
||||||
/// the whole turn is retried a single time before bubbling `AuthFailed` to
|
/// the whole turn is retried a single time before bubbling `AuthFailed` to
|
||||||
/// the serve loop (which parks for re-login).
|
/// the serve loop (which parks for re-login).
|
||||||
///
|
///
|
||||||
/// Called once per turn by the `hive-agent` serve loop, which owns the shared
|
/// Called once per turn by the `hive` serve loop, which owns the shared
|
||||||
/// `session` ([`make_session`]) and threads it in.
|
/// `session` ([`make_session`]) and threads it in.
|
||||||
pub async fn drive_turn(
|
pub async fn drive_turn(
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ chrono.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
bcrypt.workspace = true
|
bcrypt.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
forgejo-api.workspace = true
|
|
||||||
url.workspace = true
|
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
clap_complete.workspace = true
|
clap_complete.workspace = true
|
||||||
clap-markdown = "0.1"
|
clap-markdown = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,8 @@ pub use repos::{
|
||||||
};
|
};
|
||||||
pub use users::{core_token, ensure_user_for, provision_user_token};
|
pub use users::{core_token, ensure_user_for, provision_user_token};
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use forgejo_api::{Auth, Forgejo};
|
use reqwest::StatusCode;
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
|
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
|
||||||
use users::{
|
use users::{
|
||||||
|
|
@ -108,20 +105,34 @@ async fn forge_admin(args: &[&str]) -> Result<String> {
|
||||||
Ok(stdout)
|
Ok(stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed Forgejo API client for the local forge ([`FORGE_HTTP`]),
|
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
|
||||||
/// authenticated as `token`. All Forgejo API calls that don't shell
|
/// and `Authorization: token <token>`, returns the HTTP status code.
|
||||||
/// out to `forgejo admin` go through clients built here — one place
|
/// All Forgejo API calls that don't shell out to `forgejo admin` go
|
||||||
/// for the base URL and auth. Tokens differ per call site (core admin
|
/// through here — one place for auth header, content-type, error
|
||||||
/// token vs per-agent tokens), so the token is passed per call; the
|
/// propagation, and the shared reqwest Client.
|
||||||
/// base URL is parsed once. Failures surface as
|
/// Returns the response status **and body**. The body lets callers log
|
||||||
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
|
/// *why* Forgejo rejected a request (e.g. the validation message on a
|
||||||
/// and the API's error message (e.g. the validation reason on a 422).
|
/// 422); status-only callers just bind `(status, _)`. Body read is
|
||||||
pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
/// best-effort — a read error yields an empty string rather than
|
||||||
static URL: OnceLock<Url> = OnceLock::new();
|
/// failing the whole call.
|
||||||
let url = URL
|
async fn forge_http(
|
||||||
.get_or_init(|| Url::parse(FORGE_HTTP).expect("FORGE_HTTP is a valid URL"))
|
method: reqwest::Method,
|
||||||
.clone();
|
url: &str,
|
||||||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
token: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<(StatusCode, String)> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.request(method, url)
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body.to_owned())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("forge HTTP request to {url}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
Ok((status, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@
|
||||||
//! boundary; moved verbatim from the `forge` module root.
|
//! boundary; moved verbatim from the `forge` module root.
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use forgejo_api::ForgejoError;
|
|
||||||
use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo};
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
use super::{CONFIG_ORG, api, core_token};
|
use super::{CONFIG_ORG, FORGE_HTTP, core_token, forge_http};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PR-based config-flow merge primitives (part of the
|
// PR-based config-flow merge primitives (part of the
|
||||||
|
|
@ -244,57 +242,35 @@ pub async fn ff_push_to_main(repo: &str, sha: &str) -> Result<(), ForgeMergeErro
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark PR `pr` as **manually merged** at `sha` (Forgejo
|
/// Mark PR `pr` as **manually merged** at `sha` (Forgejo
|
||||||
/// `repo_merge_pull_request` with `Do=manually-merged`, `MergeCommitID=sha`).
|
/// `POST …/pulls/{pr}/merge` with `Do=manually-merged`, `MergeCommitID=sha`).
|
||||||
/// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires
|
/// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires
|
||||||
/// the branch already be at the merge commit). On an API rejection, re-reads
|
/// the branch already be at the merge commit). On a non-2xx, re-reads the PR
|
||||||
/// the PR head to distinguish drift (`HeadDrift`) from a generic failure
|
/// head to distinguish drift (`HeadDrift`) from a generic failure (`Other`) —
|
||||||
/// (`Other`) — best-effort, since the handler's pre-merge head re-read is
|
/// best-effort, since the handler's pre-merge head re-read is the real gate.
|
||||||
/// the real gate. A transport failure skips the drift re-read (it couldn't
|
|
||||||
/// reach the forge either) and surfaces as `Other` directly.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise.
|
/// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise.
|
||||||
pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
|
pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
|
||||||
let token = core_token()
|
let token = core_token()
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||||
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge");
|
||||||
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#);
|
||||||
})?;
|
let (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body)
|
||||||
let index = i64::try_from(pr)
|
|
||||||
.map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?;
|
|
||||||
let body = MergePullRequestOption {
|
|
||||||
r#do: MergePullRequestOptionDo::ManuallyMerged,
|
|
||||||
merge_commit_id: Some(sha.to_owned()),
|
|
||||||
merge_message_field: None,
|
|
||||||
merge_title_field: None,
|
|
||||||
delete_branch_after_merge: None,
|
|
||||||
force_merge: None,
|
|
||||||
head_commit_id: None,
|
|
||||||
merge_when_checks_succeed: None,
|
|
||||||
};
|
|
||||||
let client = api(&token).map_err(ForgeMergeError::Other)?;
|
|
||||||
match client
|
|
||||||
.repo_merge_pull_request(owner, name, index, body)
|
|
||||||
.await
|
.await
|
||||||
{
|
.context("POST pulls/<pr>/merge (manually-merged)")?;
|
||||||
Ok(()) => Ok(()),
|
if status.is_success() {
|
||||||
Err(ForgejoError::ReqwestError(e)) => Err(ForgeMergeError::Other(
|
return Ok(());
|
||||||
anyhow::Error::from(e).context("POST pulls/<pr>/merge (manually-merged)"),
|
}
|
||||||
)),
|
// Best-effort drift detection: if the live head no longer matches `sha`,
|
||||||
Err(e) => {
|
// that's a head-drift race; otherwise surface as a hard failure.
|
||||||
// Best-effort drift detection: if the live head no longer matches
|
match pr_head_sha(repo, pr).await {
|
||||||
// `sha`, that's a head-drift race; otherwise surface as a hard
|
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
||||||
// failure.
|
expected: sha.to_string(),
|
||||||
match pr_head_sha(repo, pr).await {
|
actual,
|
||||||
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
}),
|
||||||
expected: sha.to_string(),
|
_ => Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||||
actual,
|
"mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}"
|
||||||
}),
|
))),
|
||||||
_ => Err(ForgeMergeError::Other(anyhow::Error::from(e).context(
|
|
||||||
format!("mark PR #{pr} in {repo} manually-merged at {sha}"),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,190 +1,99 @@
|
||||||
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
||||||
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
||||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
||||||
//! pull-mirrors, and branch-protection rules. The typed API-client
|
//! pull-mirrors, and branch-protection rules. Shared HTTP helpers +
|
||||||
//! constructor + org-name constants live in the module root (`super`).
|
//! org-name constants live in the module root (`super`).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use forgejo_api::structs::{
|
|
||||||
AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption,
|
|
||||||
CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission,
|
|
||||||
EditRepoOption, MigrateRepoOptions, MigrateRepoOptionsService, Repository,
|
|
||||||
};
|
|
||||||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AGENTS_ORG, CONFIG_ORG, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO,
|
AGENTS_ORG, CONFIG_ORG, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO,
|
||||||
SHARED_ORG, api, core_token, is_present,
|
SHARED_ORG, core_token, forge_http, is_present,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Creation options for an empty repo defaulting to `main`.
|
/// JSON body for a private, empty repo defaulting to `main`.
|
||||||
fn repo_option(name: &str, private: bool) -> CreateRepoOption {
|
fn repo_body(name: &str) -> String {
|
||||||
CreateRepoOption {
|
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
||||||
auto_init: Some(false),
|
|
||||||
default_branch: Some("main".to_owned()),
|
|
||||||
description: None,
|
|
||||||
gitignores: None,
|
|
||||||
issue_labels: None,
|
|
||||||
license: None,
|
|
||||||
name: name.to_owned(),
|
|
||||||
object_format_name: None,
|
|
||||||
private: Some(private),
|
|
||||||
readme: None,
|
|
||||||
template: None,
|
|
||||||
trust_model: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `EditRepoOption` with every field unset — repo edits only ever
|
/// JSON body for a public, empty repo defaulting to `main`.
|
||||||
/// change the one field the caller sets on top (Forgejo leaves `None`
|
fn repo_body_public(name: &str) -> String {
|
||||||
/// fields untouched).
|
format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#)
|
||||||
fn sparse_edit_repo_option() -> EditRepoOption {
|
|
||||||
EditRepoOption {
|
|
||||||
allow_fast_forward_only_merge: None,
|
|
||||||
allow_manual_merge: None,
|
|
||||||
allow_merge_commits: None,
|
|
||||||
allow_rebase: None,
|
|
||||||
allow_rebase_explicit: None,
|
|
||||||
allow_rebase_update: None,
|
|
||||||
allow_squash_merge: None,
|
|
||||||
archived: None,
|
|
||||||
autodetect_manual_merge: None,
|
|
||||||
default_allow_maintainer_edit: None,
|
|
||||||
default_branch: None,
|
|
||||||
default_delete_branch_after_merge: None,
|
|
||||||
default_merge_style: None,
|
|
||||||
default_update_style: None,
|
|
||||||
description: None,
|
|
||||||
enable_prune: None,
|
|
||||||
external_tracker: None,
|
|
||||||
external_wiki: None,
|
|
||||||
globally_editable_wiki: None,
|
|
||||||
has_actions: None,
|
|
||||||
has_issues: None,
|
|
||||||
has_packages: None,
|
|
||||||
has_projects: None,
|
|
||||||
has_pull_requests: None,
|
|
||||||
has_releases: None,
|
|
||||||
has_wiki: None,
|
|
||||||
ignore_whitespace_conflicts: None,
|
|
||||||
internal_tracker: None,
|
|
||||||
mirror_interval: None,
|
|
||||||
name: None,
|
|
||||||
private: None,
|
|
||||||
template: None,
|
|
||||||
website: None,
|
|
||||||
wiki_branch: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a create-style call failed because the object already
|
|
||||||
/// exists. Forgejo signals this as HTTP 409 (conflict) or 422
|
|
||||||
/// (validation). The typed client surfaces those as
|
|
||||||
/// `ApiErrorKind::Other(409)` / `ValidationFailed` when the endpoint
|
|
||||||
/// spec lists the status, or as a bare `UnexpectedStatusCode`
|
|
||||||
/// otherwise — match all shapes defensively.
|
|
||||||
fn is_already_exists(e: &ForgejoError) -> bool {
|
|
||||||
match e {
|
|
||||||
ForgejoError::ApiError(api) => match api.error_kind() {
|
|
||||||
ApiErrorKind::ValidationFailed => true,
|
|
||||||
ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT,
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
ForgejoError::UnexpectedStatusCode(s) => {
|
|
||||||
*s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an error is specifically an HTTP 409 conflict (and NOT a
|
|
||||||
/// 422): the migrate endpoint's 422 is a validation error (bad
|
|
||||||
/// `clone_addr` / service) and must surface, so it can't share
|
|
||||||
/// [`is_already_exists`]'s 422 tolerance.
|
|
||||||
fn is_conflict(e: &ForgejoError) -> bool {
|
|
||||||
match e {
|
|
||||||
ForgejoError::ApiError(api) => {
|
|
||||||
matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::CONFLICT)
|
|
||||||
}
|
|
||||||
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::CONFLICT,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an error is Forgejo saying 404 — the resource is absent,
|
|
||||||
/// as opposed to a transport / auth / server failure.
|
|
||||||
fn is_not_found(e: &ForgejoError) -> bool {
|
|
||||||
match e {
|
|
||||||
ForgejoError::ApiError(api) => {
|
|
||||||
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })
|
|
||||||
}
|
|
||||||
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fold a repo-creation result's "already exists" (409 / 422) into
|
|
||||||
/// success. `label` is `<owner>/<name>` — purely for log + error
|
|
||||||
/// context.
|
|
||||||
fn created_or_exists(res: Result<Repository, ForgejoError>, label: &str) -> Result<()> {
|
|
||||||
match res {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%label, "forge: created repo");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) if is_already_exists(&e) => {
|
|
||||||
tracing::debug!(%label, "forge: repo already exists");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(e).with_context(|| format!("create repo {label}")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set an existing repo to public visibility. No-op if the repo is
|
/// Set an existing repo to public visibility. No-op if the repo is
|
||||||
/// already public. Used for `internal/knowledge` which may have been
|
/// already public. Used for `internal/knowledge` which may have been
|
||||||
/// created as private on an older deployment.
|
/// created as private on an older deployment.
|
||||||
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
||||||
let mut edit = sparse_edit_repo_option();
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||||
edit.private = Some(false);
|
let (status, _) =
|
||||||
api(token)?
|
forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?;
|
||||||
.repo_edit(owner, repo, edit)
|
match status.as_u16() {
|
||||||
.await
|
200 => {
|
||||||
.with_context(|| format!("edit {owner}/{repo} (set public)"))?;
|
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
||||||
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
Ok(())
|
||||||
Ok(())
|
}
|
||||||
|
other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create `name` inside org `org` as a public repo. Idempotent.
|
/// Create `name` inside org `org` as a public repo. Idempotent.
|
||||||
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
||||||
let res = api(token)?
|
create_repo(
|
||||||
.create_org_repo(org, repo_option(name, false))
|
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||||
.await;
|
&repo_body_public(name),
|
||||||
created_or_exists(res, &format!("{org}/{name}"))
|
token,
|
||||||
|
&format!("{org}/{name}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST a repo-creation request to `url` and fold "already exists"
|
||||||
|
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
|
||||||
|
/// for log + error context.
|
||||||
|
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
|
||||||
|
let (status, _) = forge_http(reqwest::Method::POST, url, token, body).await?;
|
||||||
|
match status.as_u16() {
|
||||||
|
201 => {
|
||||||
|
tracing::info!(%label, "forge: created repo");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
409 | 422 => {
|
||||||
|
tracing::debug!(%label, "forge: repo already exists");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a repo in the token-owner's own namespace. `token` belongs
|
/// Create a repo in the token-owner's own namespace. `token` belongs
|
||||||
/// to the user we want the repo owned by (we use `core`'s token for
|
/// to the user we want the repo owned by (we use `core`'s token for
|
||||||
/// `core/meta`). Idempotent.
|
/// `core/meta`). Idempotent.
|
||||||
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
||||||
let res = api(token)?
|
create_repo(
|
||||||
.create_current_user_repo(repo_option(name, true))
|
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
||||||
.await;
|
&repo_body(name),
|
||||||
created_or_exists(res, &format!("core/{name}"))
|
token,
|
||||||
|
&format!("core/{name}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
||||||
let res = api(token)?
|
create_repo(
|
||||||
.create_org_repo(org, repo_option(name, true))
|
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||||
.await;
|
&repo_body(name),
|
||||||
created_or_exists(res, &format!("{org}/{name}"))
|
token,
|
||||||
|
&format!("{org}/{name}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
||||||
|
|
@ -232,14 +141,7 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
||||||
ensure_org_repo(CONFIG_ORG, name, &token).await?;
|
ensure_org_repo(CONFIG_ORG, name, &token).await?;
|
||||||
// Agent = write collaborator: it can push config-PR branches + open PRs,
|
// Agent = write collaborator: it can push config-PR branches + open PRs,
|
||||||
// but the branch protection below keeps it off `main` directly.
|
// but the branch protection below keeps it off `main` directly.
|
||||||
add_collaborator(
|
add_collaborator(CONFIG_ORG, name, name, "write", &token).await?;
|
||||||
CONFIG_ORG,
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
AddCollaboratorOptionPermission::Write,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
// Protect `main` core-only, fast-forward-only (no auto force-push).
|
// Protect `main` core-only, fast-forward-only (no auto force-push).
|
||||||
apply_config_repo_branch_protection(name, &token).await
|
apply_config_repo_branch_protection(name, &token).await
|
||||||
}
|
}
|
||||||
|
|
@ -252,20 +154,43 @@ pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
||||||
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo
|
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
||||||
/// answers 204 either way). Mirrors `meta_read_access` so agents can
|
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
|
||||||
/// clone the shared docs repo without authentication hassle.
|
/// without authentication hassle.
|
||||||
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
add_collaborator(
|
let url =
|
||||||
SHARED_ORG,
|
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
|
||||||
SHARED_DOCS_REPO,
|
let body = r#"{"permission":"read"}"#;
|
||||||
name,
|
let out = Command::new("curl")
|
||||||
AddCollaboratorOptionPermission::Read,
|
.args([
|
||||||
core_token,
|
"-sS",
|
||||||
)
|
"-o",
|
||||||
.await?;
|
"/dev/null",
|
||||||
tracing::info!(%name, "forge: granted shared-docs read access");
|
"-w",
|
||||||
Ok(())
|
"%{http_code}",
|
||||||
|
"-X",
|
||||||
|
"PUT",
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"-H",
|
||||||
|
&format!("Authorization: token {core_token}"),
|
||||||
|
"-d",
|
||||||
|
body,
|
||||||
|
&url,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("invoke curl PUT internal/docs/collaborators")?;
|
||||||
|
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||||
|
match code.as_str() {
|
||||||
|
"204" => {
|
||||||
|
tracing::info!(%name, "forge: granted shared-docs read access");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => anyhow::bail!(
|
||||||
|
"PUT {SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name} returned HTTP {other}"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the `internal/knowledge` repo exists and is public.
|
/// Ensure the `internal/knowledge` repo exists and is public.
|
||||||
|
|
@ -283,19 +208,38 @@ pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
|
||||||
|
|
||||||
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
||||||
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
||||||
/// re-adding an existing collaborator succeeds (Forgejo answers 204
|
/// HTTP 204 (already a collaborator) is treated as success.
|
||||||
/// either way).
|
|
||||||
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
add_collaborator(
|
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
||||||
"core",
|
let body = r#"{"permission":"read"}"#;
|
||||||
"meta",
|
let out = Command::new("curl")
|
||||||
name,
|
.args([
|
||||||
AddCollaboratorOptionPermission::Read,
|
"-sS",
|
||||||
core_token,
|
"-o",
|
||||||
)
|
"/dev/null",
|
||||||
.await?;
|
"-w",
|
||||||
tracing::info!(%name, "forge: granted meta read access");
|
"%{http_code}",
|
||||||
Ok(())
|
"-X",
|
||||||
|
"PUT",
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"-H",
|
||||||
|
&format!("Authorization: token {core_token}"),
|
||||||
|
"-d",
|
||||||
|
body,
|
||||||
|
&url,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("invoke curl PUT core/meta/collaborators")?;
|
||||||
|
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||||
|
match code.as_str() {
|
||||||
|
"204" => {
|
||||||
|
tracing::info!(%name, "forge: granted meta read access");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("PUT core/meta/collaborators/{name} returned HTTP {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
||||||
|
|
@ -395,29 +339,22 @@ pub async fn push_config(name: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an org named `name` (`org_create`). Idempotent: HTTP 422
|
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
||||||
/// ("user already exists") / 409 is treated as success.
|
/// HTTP 422 ("user already exists") is treated as success.
|
||||||
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
||||||
let org = CreateOrgOption {
|
let body = format!(r#"{{"username":"{name}"}}"#);
|
||||||
description: None,
|
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
||||||
email: None,
|
let (status, _) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||||
full_name: None,
|
match status.as_u16() {
|
||||||
location: None,
|
201 => {
|
||||||
repo_admin_change_team_access: None,
|
|
||||||
username: name.to_owned(),
|
|
||||||
visibility: None,
|
|
||||||
website: None,
|
|
||||||
};
|
|
||||||
match api(admin_token)?.org_create(org).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%name, "forge: created org");
|
tracing::info!(%name, "forge: created org");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) if is_already_exists(&e) => {
|
422 | 409 => {
|
||||||
tracing::debug!(%name, "forge: org already exists");
|
tracing::debug!(%name, "forge: org already exists");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e).with_context(|| format!("create org {name}")),
|
other => anyhow::bail!("POST /api/v1/orgs name={name} returned HTTP {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,82 +414,63 @@ const MIRROR_INTERVAL: &str = "8h0m0s";
|
||||||
/// Idempotent: if the repo already exists this function patches its
|
/// Idempotent: if the repo already exists this function patches its
|
||||||
/// `mirror_interval` to ensure it matches (covers mirrors that were
|
/// `mirror_interval` to ensure it matches (covers mirrors that were
|
||||||
/// created before the interval was introduced). A 409 on the migrate
|
/// created before the interval was introduced). A 409 on the migrate
|
||||||
/// call (a race between the existence check and the migrate) is also
|
/// POST (a race between the GET check and the POST) is also success.
|
||||||
/// success.
|
|
||||||
async fn ensure_mirror_repo(
|
async fn ensure_mirror_repo(
|
||||||
upstream: &str,
|
upstream: &str,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
admin_token: &str,
|
admin_token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let client = api(admin_token)?;
|
let repo_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||||
match client.repo_get(owner, repo).await {
|
let (status, _) = forge_http(reqwest::Method::GET, &repo_url, admin_token, "").await?;
|
||||||
Ok(_) => {
|
if status.is_success() {
|
||||||
// Mirror already present. Patch interval so mirrors seeded before
|
// Mirror already present. Patch interval so mirrors seeded before
|
||||||
// this field was introduced (or with a different value) converge.
|
// this field was introduced (or with a different value) converge.
|
||||||
let mut edit = sparse_edit_repo_option();
|
let patch_body = serde_json::json!({ "mirror_interval": MIRROR_INTERVAL }).to_string();
|
||||||
edit.mirror_interval = Some(MIRROR_INTERVAL.to_owned());
|
let (patch_status, patch_text) =
|
||||||
match client.repo_edit(owner, repo, edit).await {
|
forge_http(reqwest::Method::PATCH, &repo_url, admin_token, &patch_body).await?;
|
||||||
Ok(_) => {
|
if patch_status.is_success() {
|
||||||
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
||||||
}
|
} else {
|
||||||
Err(e) => {
|
tracing::warn!(
|
||||||
tracing::warn!(
|
%owner, %repo, status = %patch_status, body = %patch_text,
|
||||||
%owner, %repo, error = %e,
|
"forge: failed to set mirror_interval on existing pull-mirror"
|
||||||
"forge: failed to set mirror_interval on existing pull-mirror"
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Absent — fall through to migrate.
|
|
||||||
Err(e) if is_not_found(&e) => {}
|
|
||||||
// Anything else (transport, auth, 5xx) leaves the repo's existence
|
|
||||||
// unknown: migrating anyway would fold a 409 into success and skip
|
|
||||||
// the interval patch this pass. Surface it instead.
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e).with_context(|| format!("get pull-mirror {owner}/{repo}"));
|
|
||||||
}
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
let opts = MigrateRepoOptions {
|
// serde_json::json! → the upstream URL is escaped safely (no string
|
||||||
auth_password: None,
|
// interpolation into the JSON body).
|
||||||
auth_token: None,
|
let body = serde_json::json!({
|
||||||
auth_username: None,
|
"clone_addr": upstream,
|
||||||
clone_addr: upstream.to_owned(),
|
"repo_owner": owner,
|
||||||
description: None,
|
"repo_name": repo,
|
||||||
issues: None,
|
"mirror": true,
|
||||||
labels: None,
|
|
||||||
lfs: None,
|
|
||||||
lfs_endpoint: None,
|
|
||||||
milestones: None,
|
|
||||||
mirror: Some(true),
|
|
||||||
// Periodic refresh instead of on-access sync — keeps CI isolated
|
// Periodic refresh instead of on-access sync — keeps CI isolated
|
||||||
// from external DNS failures at clone time.
|
// from external DNS failures at clone time.
|
||||||
mirror_interval: Some(MIRROR_INTERVAL.to_owned()),
|
"interval": MIRROR_INTERVAL,
|
||||||
private: Some(false),
|
"service": "git",
|
||||||
pull_requests: None,
|
"private": false,
|
||||||
releases: None,
|
})
|
||||||
repo_name: repo.to_owned(),
|
.to_string();
|
||||||
repo_owner: Some(owner.to_owned()),
|
let url = format!("{FORGE_HTTP}/api/v1/repos/migrate");
|
||||||
service: Some(MigrateRepoOptionsService::Git),
|
let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||||
uid: None,
|
match status.as_u16() {
|
||||||
wiki: None,
|
201 => {
|
||||||
};
|
|
||||||
match client.repo_migrate(opts).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
|
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// 409 = a race created it between our existence check and here (the
|
// 409 = a race created it between our GET check and here (the GET
|
||||||
// check is the real idempotency guard). NOT 422: for the migrate
|
// is the real idempotency guard). NOT 422: for the migrate endpoint
|
||||||
// endpoint 422 is a validation error (bad clone_addr / service), so
|
// 422 is a validation error (bad clone_addr / service), so it must
|
||||||
// it must surface via the error arm, not be swallowed as "already
|
// surface via the bail arm, not be swallowed as "already exists".
|
||||||
// exists" — hence `is_conflict`, not `is_already_exists`.
|
409 => {
|
||||||
Err(e) if is_conflict(&e) => {
|
|
||||||
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
|
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")),
|
other => {
|
||||||
|
anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {text}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,101 +488,48 @@ async fn ensure_mirror_repo(
|
||||||
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
||||||
/// pipeline and silently didn't apply.
|
/// pipeline and silently didn't apply.
|
||||||
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
||||||
let team = CreateTeamOption {
|
let url = format!("{FORGE_HTTP}/api/v1/orgs/{org}/teams");
|
||||||
can_create_org_repo: Some(false),
|
let body = format!(
|
||||||
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
|
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
|
||||||
includes_all_repositories: Some(true),
|
);
|
||||||
name: OPERATORS_TEAM.to_owned(),
|
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
permission: Some(CreateTeamOptionPermission::Write),
|
match status.as_u16() {
|
||||||
units: None,
|
201 => {
|
||||||
units_map: None,
|
|
||||||
};
|
|
||||||
match api(token)?.org_create_team(org, team).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) if is_already_exists(&e) => {
|
409 | 422 => {
|
||||||
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")),
|
other => {
|
||||||
|
anyhow::bail!("POST /orgs/{org}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `user` as a collaborator on `owner/repo` at `permission`.
|
/// Add `user` as a collaborator on `owner/repo` at `permission`
|
||||||
/// Idempotent: adding an existing collaborator just updates its
|
/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a
|
||||||
/// permission (Forgejo answers 204 either way; a 201 from older
|
/// collaborator / permission updated) both count as success.
|
||||||
/// versions is tolerated defensively).
|
|
||||||
async fn add_collaborator(
|
async fn add_collaborator(
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
user: &str,
|
user: &str,
|
||||||
permission: AddCollaboratorOptionPermission,
|
permission: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let res = api(token)?
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}");
|
||||||
.repo_add_collaborator(
|
let body = format!(r#"{{"permission":"{permission}"}}"#);
|
||||||
owner,
|
let (status, _) = forge_http(reqwest::Method::PUT, &url, token, &body).await?;
|
||||||
repo,
|
match status.as_u16() {
|
||||||
user,
|
201 | 204 => {
|
||||||
AddCollaboratorOption {
|
tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set");
|
||||||
permission: Some(permission),
|
Ok(())
|
||||||
},
|
}
|
||||||
)
|
other => {
|
||||||
.await;
|
anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}")
|
||||||
match res {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e).with_context(|| format!("add collaborator {user} to {owner}/{repo}"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::debug!(%owner, %repo, %user, ?permission, "forge: collaborator set");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `CreateBranchProtectionOption` protecting `main` with every other
|
|
||||||
/// field unset — Forgejo treats `None` fields as their defaults, same
|
|
||||||
/// as the sparse JSON bodies the raw-HTTP predecessor sent. Callers
|
|
||||||
/// set the whitelist/approval fields they need on top.
|
|
||||||
fn main_branch_protection_option() -> CreateBranchProtectionOption {
|
|
||||||
CreateBranchProtectionOption {
|
|
||||||
apply_to_admins: None,
|
|
||||||
approvals_whitelist_teams: None,
|
|
||||||
approvals_whitelist_username: None,
|
|
||||||
block_on_official_review_requests: None,
|
|
||||||
block_on_outdated_branch: None,
|
|
||||||
block_on_rejected_reviews: None,
|
|
||||||
branch_name: Some("main".to_owned()),
|
|
||||||
dismiss_stale_approvals: None,
|
|
||||||
enable_approvals_whitelist: None,
|
|
||||||
enable_merge_whitelist: None,
|
|
||||||
enable_push: None,
|
|
||||||
enable_push_whitelist: None,
|
|
||||||
enable_status_check: None,
|
|
||||||
ignore_stale_approvals: None,
|
|
||||||
merge_whitelist_teams: None,
|
|
||||||
merge_whitelist_usernames: None,
|
|
||||||
protected_file_patterns: None,
|
|
||||||
push_whitelist_deploy_keys: None,
|
|
||||||
push_whitelist_teams: None,
|
|
||||||
push_whitelist_usernames: None,
|
|
||||||
require_signed_commits: None,
|
|
||||||
required_approvals: None,
|
|
||||||
rule_name: None,
|
|
||||||
status_check_contexts: None,
|
|
||||||
unprotected_file_patterns: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a branch-protection create failed because a rule for the
|
|
||||||
/// branch already exists: 409/422 ([`is_already_exists`]) or the 200
|
|
||||||
/// Forgejo answers instead of 201 for a duplicate rule (unlisted in
|
|
||||||
/// the endpoint spec, so it surfaces as `UnexpectedStatusCode(200)`).
|
|
||||||
fn is_protection_already_present(e: &ForgejoError) -> bool {
|
|
||||||
is_already_exists(e)
|
|
||||||
|| matches!(e, ForgejoError::UnexpectedStatusCode(s) if *s == StatusCode::OK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the operator merge-gate branch protection to `repo`'s default
|
/// Apply the operator merge-gate branch protection to `repo`'s default
|
||||||
|
|
@ -673,26 +538,21 @@ fn is_protection_already_present(e: &ForgejoError) -> bool {
|
||||||
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
||||||
/// rule for the branch (200/409/422) is treated as success.
|
/// rule for the branch (200/409/422) is treated as success.
|
||||||
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
||||||
let mut rule = main_branch_protection_option();
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections");
|
||||||
rule.enable_merge_whitelist = Some(true);
|
let body = format!(
|
||||||
rule.merge_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
|
r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"#
|
||||||
rule.enable_approvals_whitelist = Some(true);
|
);
|
||||||
rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
|
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
rule.required_approvals = Some(1);
|
match status.as_u16() {
|
||||||
rule.block_on_official_review_requests = Some(true);
|
201 => {
|
||||||
match api(token)?
|
|
||||||
.repo_create_branch_protection(AGENTS_ORG, repo, rule)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%repo, "forge: applied operator branch protection");
|
tracing::info!(%repo, "forge: applied operator branch protection");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) if is_protection_already_present(&e) => {
|
200 | 409 | 422 => {
|
||||||
tracing::debug!(%repo, "forge: branch protection already present");
|
tracing::debug!(%repo, "forge: branch protection already present");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e).with_context(|| format!("create branch protection {AGENTS_ORG}/{repo}")),
|
other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,63 +564,45 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()>
|
||||||
/// (`run_merge_config_pr`). The agent can never push `main` directly.
|
/// (`run_merge_config_pr`). The agent can never push `main` directly.
|
||||||
/// - **operator-team approval is required** to merge, and the author (not in
|
/// - **operator-team approval is required** to merge, and the author (not in
|
||||||
/// the team) cannot self-approve.
|
/// the team) cannot self-approve.
|
||||||
/// - **force-pushing `main` stays impossible** — `main` only ever advances by
|
/// - **`enable_force_push` is `false`** — `main` only ever advances by
|
||||||
/// fast-forward. The merge handler's `ff_push_to_main` is already a
|
/// fast-forward. The merge handler's `ff_push_to_main` is already a
|
||||||
/// non-force push, so it lands fine. The legacy `push_config` mirror DOES
|
/// non-force push, so it lands fine. The legacy `push_config` mirror DOES
|
||||||
/// force-push (it re-points status tags and rewinds `main` on a failed-build
|
/// force-push (it re-points status tags and rewinds `main` on a failed-build
|
||||||
/// rollback), so the protection rejects those non-ff updates — that
|
/// rollback), so the protection now rejects those non-ff updates — that
|
||||||
/// mirror runs best-effort until the agent-opened PR-merge flow retires it.
|
/// mirror runs best-effort until the agent-opened PR-merge flow retires it.
|
||||||
/// (Auto force-push is intentionally not allowed: per operator directive a
|
/// (Auto force-push is intentionally not allowed: per operator directive a
|
||||||
/// silent force-push is a bug, not a feature. The raw-HTTP predecessor
|
/// silent force-push is a bug, not a feature.)
|
||||||
/// sent `"enable_force_push":false` + `"allow_manual_merge":true` in this
|
|
||||||
/// body; neither is a `CreateBranchProtectionOption` field, so Forgejo
|
|
||||||
/// ignored both keys — dropping them changes nothing: force-push
|
|
||||||
/// protection defaults to off, and `allow_manual_merge` is a *repo*
|
|
||||||
/// setting, not a branch-protection one.)
|
|
||||||
///
|
///
|
||||||
/// Idempotent: an existing rule for the branch (200/409/422) is success.
|
/// Idempotent: an existing rule for the branch (200/409/422) is success.
|
||||||
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
|
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
|
||||||
let client = api(token)?;
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections");
|
||||||
let mut rule = main_branch_protection_option();
|
let body = format!(
|
||||||
rule.enable_push_whitelist = Some(true);
|
r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":false}}"#
|
||||||
rule.push_whitelist_usernames = Some(vec!["core".to_owned()]);
|
);
|
||||||
rule.enable_merge_whitelist = Some(true);
|
let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
rule.merge_whitelist_usernames = Some(vec!["core".to_owned()]);
|
if status.as_u16() == 201 {
|
||||||
rule.enable_approvals_whitelist = Some(true);
|
|
||||||
rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
|
|
||||||
rule.required_approvals = Some(1);
|
|
||||||
rule.block_on_official_review_requests = Some(true);
|
|
||||||
let Err(create_err) = client
|
|
||||||
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
tracing::info!(%repo, "forge: applied config-repo branch protection");
|
tracing::info!(%repo, "forge: applied config-repo branch protection");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
// A create failure is ambiguous: it can mean "rule already exists"
|
// Non-201 is ambiguous: it can mean "rule already exists" (idempotent
|
||||||
// (idempotent success) OR a silent rejection — e.g. a 422 where Forgejo
|
// success) OR a silent rejection — e.g. a 422 where Forgejo refused
|
||||||
// refused the request and created NO rule. The old code treated
|
// the request and created NO rule. The old code treated 200/409/422
|
||||||
// 200/409/422 all as success, so a rejected POST left the repo
|
// all as success, so a rejected POST left the repo unprotected with
|
||||||
// unprotected with no error (the reported case: a new agent's config
|
// no error (the reported case: a new agent's config repo had no
|
||||||
// repo had no `main` rule and nothing was logged). Don't trust the
|
// `main` rule and nothing was logged). Don't trust the status code:
|
||||||
// status: verify the `main` rule actually exists, and on failure
|
// verify the `main` rule actually exists, and on failure surface the
|
||||||
// surface the create error (its Display carries Forgejo's validation
|
// POST's response body so the real reason is in the journal.
|
||||||
// message) so the real reason is in the journal.
|
let main_url = format!("{url}/main");
|
||||||
match client
|
let (check, _) = forge_http(reqwest::Method::GET, &main_url, token, "").await?;
|
||||||
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
|
if check.as_u16() == 200 {
|
||||||
.await
|
tracing::debug!(%repo, %status, "forge: config-repo branch protection already present");
|
||||||
{
|
Ok(())
|
||||||
Ok(_) => {
|
} else {
|
||||||
tracing::debug!(
|
anyhow::bail!(
|
||||||
%repo, create_error = %create_err,
|
"branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \
|
||||||
"forge: config-repo branch protection already present"
|
(body: {body}); GET main -> HTTP {check}, no `main` rule present",
|
||||||
);
|
body = resp_body.trim(),
|
||||||
Ok(())
|
)
|
||||||
}
|
|
||||||
Err(check_err) => anyhow::bail!(
|
|
||||||
"branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
|
|
||||||
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -772,14 +614,7 @@ async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<
|
||||||
/// create repos directly (`max_repo_creation = 0`). Idempotent.
|
/// create repos directly (`max_repo_creation = 0`). Idempotent.
|
||||||
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
||||||
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
|
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
|
||||||
add_collaborator(
|
add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?;
|
||||||
AGENTS_ORG,
|
|
||||||
repo,
|
|
||||||
agent,
|
|
||||||
AddCollaboratorOptionPermission::Write,
|
|
||||||
core_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
apply_operator_branch_protection(repo, core_token).await?;
|
apply_operator_branch_protection(repo, core_token).await?;
|
||||||
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
||||||
Ok(format!("{AGENTS_ORG}/{repo}"))
|
Ok(format!("{AGENTS_ORG}/{repo}"))
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
//! Per-agent Forgejo user + access-token provisioning, account
|
//! Per-agent Forgejo user + access-token provisioning, account
|
||||||
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
||||||
//! and the bootstrap `core` admin user + token lifecycle. The typed
|
//! and the bootstrap `core` admin user + token lifecycle. Shared
|
||||||
//! API-client constructor + `forgejo admin` helpers live in the
|
//! HTTP / `forgejo admin` helpers live in the module root (`super`).
|
||||||
//! module root (`super`).
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption};
|
|
||||||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
use super::{CONFIG_ORG, api, forge_admin, is_present};
|
use super::{CONFIG_ORG, FORGE_HTTP, forge_admin, forge_http, is_present};
|
||||||
|
|
||||||
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||||
/// Where the host-side `core` admin token lives. Used by hive-c0re
|
/// Where the host-side `core` admin token lives. Used by hive-c0re
|
||||||
|
|
@ -58,49 +55,6 @@ fn agent_email(name: &str) -> String {
|
||||||
format!("{name}@hyperhive.local")
|
format!("{name}@hyperhive.local")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `EditUserOption` with every field unset except the ones Forgejo's
|
|
||||||
/// validator effectively requires: `login_name` and `source_id = 0`
|
|
||||||
/// (local auth, the default for users hive-c0re creates). Omitting
|
|
||||||
/// `login_name` made Forgejo reset `use_custom_avatar` on every admin
|
|
||||||
/// edit — the same reason the old raw JSON bodies always carried both
|
|
||||||
/// fields. Callers set only the field(s) they mean to change on top.
|
|
||||||
fn sparse_edit_user_option(name: &str) -> EditUserOption {
|
|
||||||
EditUserOption {
|
|
||||||
active: None,
|
|
||||||
admin: None,
|
|
||||||
allow_create_organization: None,
|
|
||||||
allow_git_hook: None,
|
|
||||||
allow_import_local: None,
|
|
||||||
description: None,
|
|
||||||
email: None,
|
|
||||||
full_name: None,
|
|
||||||
hide_email: None,
|
|
||||||
location: None,
|
|
||||||
login_name: Some(name.to_owned()),
|
|
||||||
max_repo_creation: None,
|
|
||||||
must_change_password: None,
|
|
||||||
password: None,
|
|
||||||
prohibit_login: None,
|
|
||||||
pronouns: None,
|
|
||||||
restricted: None,
|
|
||||||
source_id: Some(0),
|
|
||||||
visibility: None,
|
|
||||||
website: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a Forgejo API error is a definitive 403. The typed client
|
|
||||||
/// maps a 403 response to `ApiErrorKind::Forbidden` when the endpoint
|
|
||||||
/// spec lists it, or to `UnexpectedStatusCode(403)` otherwise — check
|
|
||||||
/// both defensively.
|
|
||||||
fn is_forbidden(e: &ForgejoError) -> bool {
|
|
||||||
match e {
|
|
||||||
ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::Forbidden),
|
|
||||||
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::FORBIDDEN,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||||
/// returns a "user already exists" error which we treat as success.
|
/// returns a "user already exists" error which we treat as success.
|
||||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
||||||
|
|
@ -175,11 +129,11 @@ async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
||||||
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
||||||
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
||||||
///
|
///
|
||||||
/// Uses the admin REST API (`admin_edit_user`, i.e. `PATCH
|
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
|
||||||
/// /api/v1/admin/users/{name}`) rather than `forgejo admin user edit`
|
/// than `forgejo admin user edit` because the CLI dropped the `edit`
|
||||||
/// because the CLI dropped the `edit` subcommand somewhere between
|
/// subcommand somewhere between forgejo 8 and current. Body includes
|
||||||
/// forgejo 8 and current. The edit body carries `login_name` +
|
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
|
||||||
/// `source_id = 0` via [`sparse_edit_user_option`].
|
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
|
||||||
pub(super) async fn ensure_user_email(name: &str) {
|
pub(super) async fn ensure_user_email(name: &str) {
|
||||||
let marker = crate::paths::forge_email_aligned_marker(name);
|
let marker = crate::paths::forge_email_aligned_marker(name);
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
|
|
@ -190,33 +144,31 @@ pub(super) async fn ensure_user_email(name: &str) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let email = agent_email(name);
|
let email = agent_email(name);
|
||||||
let mut edit = sparse_edit_user_option(name);
|
// `login_name` is required by Forgejo's EditUserOption validator.
|
||||||
edit.email = Some(email.clone());
|
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
|
||||||
let client = match api(&token) {
|
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
|
||||||
Ok(c) => c,
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||||
Err(e) => {
|
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||||
tracing::warn!(%name, error = %e, "forge: PATCH user email: client build failed");
|
Ok((status, _)) if status.is_success() => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match client.admin_edit_user(name, edit).await {
|
|
||||||
Ok(_) => {
|
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
std::fs::write(&marker, "").ok();
|
std::fs::write(&marker, "").ok();
|
||||||
tracing::info!(%name, %email, "forge: user email aligned");
|
tracing::info!(%name, %email, "forge: user email aligned");
|
||||||
}
|
}
|
||||||
Err(e) if is_forbidden(&e) => {
|
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||||
// Core token missing admin scope — see
|
// Core token missing admin scope — see
|
||||||
// `docs/forge.md::Token scopes` migration note.
|
// `docs/forge.md::Token scopes` migration note.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%name, %email, error = %e,
|
%name, %email, %status,
|
||||||
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
||||||
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"),
|
Ok((status, _)) => {
|
||||||
|
tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success");
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,12 +183,11 @@ pub(super) async fn ensure_user_email(name: &str) {
|
||||||
/// so creation is refused while push / PR / clone stay intact. **Existing
|
/// so creation is refused while push / PR / clone stay intact. **Existing
|
||||||
/// repos are untouched** — this only blocks *new* direct creation.
|
/// repos are untouched** — this only blocks *new* direct creation.
|
||||||
///
|
///
|
||||||
/// Marker-guarded like [`ensure_user_email`]: the edit runs once per
|
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
|
||||||
/// agent (delete the marker to re-apply). The edit body carries
|
/// agent (delete the marker to re-apply). Body carries `login_name` +
|
||||||
/// `login_name` + `source_id` for the same reason `ensure_user_email`
|
/// `source_id` for the same reason `ensure_user_email` does — omitting
|
||||||
/// does — omitting `login_name` makes Forgejo's `EditUserOption`
|
/// `login_name` makes Forgejo's `EditUserOption` validator reset
|
||||||
/// validator reset `use_custom_avatar`. Best-effort: failures warn,
|
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
|
||||||
/// don't propagate.
|
|
||||||
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
||||||
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
|
|
@ -246,32 +197,28 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
||||||
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut edit = sparse_edit_user_option(name);
|
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
|
||||||
edit.max_repo_creation = Some(0);
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||||
let client = match api(&token) {
|
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||||
Ok(c) => c,
|
Ok((status, _)) if status.is_success() => {
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation: client build failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match client.admin_edit_user(name, edit).await {
|
|
||||||
Ok(_) => {
|
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
std::fs::write(&marker, "").ok();
|
std::fs::write(&marker, "").ok();
|
||||||
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
||||||
}
|
}
|
||||||
Err(e) if is_forbidden(&e) => {
|
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%name, error = %e,
|
%name, %status,
|
||||||
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
||||||
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Ok((status, _)) => {
|
||||||
|
tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success");
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation failed");
|
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -390,17 +337,15 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
||||||
let png_bytes = tokio::fs::read(&png_path)
|
let png_bytes = tokio::fs::read(&png_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
||||||
// The raw-HTTP predecessor POSTed the admin endpoint
|
let body = format!(
|
||||||
// `/admin/users/core/avatar`, which forgejo-api has no method for.
|
r#"{{"image":"{}"}}"#,
|
||||||
// `token` IS the core user's own token though, so updating "the
|
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||||
// current user's avatar" (`POST /user/avatar`) is behaviorally
|
);
|
||||||
// identical.
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
||||||
api(token)?
|
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
.user_update_avatar(UpdateUserAvatarOption {
|
if !status.is_success() {
|
||||||
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
anyhow::bail!("set core avatar: HTTP {status}");
|
||||||
})
|
}
|
||||||
.await
|
|
||||||
.context("set core avatar")?;
|
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -411,8 +356,9 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
||||||
|
|
||||||
/// Set the `agent-configs` org's Forgejo avatar to the
|
/// Set the `agent-configs` org's Forgejo avatar to the
|
||||||
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
||||||
/// one-shot, marker-guarded, best-effort. Uses `org_update_avatar`
|
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
||||||
/// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload).
|
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
||||||
|
/// JSON body — same shape as the admin user endpoint above.
|
||||||
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||||
let marker = crate::paths::forge_config_org_avatar_marker();
|
let marker = crate::paths::forge_config_org_avatar_marker();
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
|
|
@ -422,15 +368,15 @@ pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||||
let png_bytes = tokio::fs::read(&png_path)
|
let png_bytes = tokio::fs::read(&png_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
||||||
api(token)?
|
let body = format!(
|
||||||
.org_update_avatar(
|
r#"{{"image":"{}"}}"#,
|
||||||
CONFIG_ORG,
|
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||||
UpdateUserAvatarOption {
|
);
|
||||||
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
||||||
},
|
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
)
|
if !status.is_success() {
|
||||||
.await
|
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
||||||
.with_context(|| format!("set {CONFIG_ORG} avatar"))?;
|
}
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -461,47 +407,32 @@ enum CoreTokenCheck {
|
||||||
Indeterminate,
|
Indeterminate,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a failed token-probe call to a [`CoreTokenCheck`]. Only a
|
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
||||||
/// definitive auth rejection (401/403 — surfaced by the typed client
|
/// Pure so the decision logic is unit-testable without a live forge.
|
||||||
/// as `Unauthorized`/`Forbidden` API errors, or defensively as bare
|
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
||||||
/// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport,
|
if status.is_success() {
|
||||||
/// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision
|
CoreTokenCheck::Valid
|
||||||
/// logic is unit-testable without a live forge.
|
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||||
fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck {
|
CoreTokenCheck::Invalid
|
||||||
match e {
|
} else {
|
||||||
ForgejoError::ApiError(api) => match api.error_kind() {
|
CoreTokenCheck::Indeterminate
|
||||||
ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden => CoreTokenCheck::Invalid,
|
|
||||||
_ => CoreTokenCheck::Indeterminate,
|
|
||||||
},
|
|
||||||
ForgejoError::UnexpectedStatusCode(s)
|
|
||||||
if *s == StatusCode::UNAUTHORIZED || *s == StatusCode::FORBIDDEN =>
|
|
||||||
{
|
|
||||||
CoreTokenCheck::Invalid
|
|
||||||
}
|
|
||||||
_ => CoreTokenCheck::Indeterminate,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether `token` is still accepted by the current forge with a
|
/// Probe whether `token` is still accepted by the current forge with a
|
||||||
/// cheap authenticated `user_get_current` (`GET /api/v1/user`, covered
|
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
||||||
/// by the core token's `read:user` scope). See [`CoreTokenCheck`] for
|
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
||||||
/// how the outcome is interpreted.
|
/// interpreted.
|
||||||
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
||||||
let Ok(client) = api(token) else {
|
let url = format!("{FORGE_HTTP}/api/v1/user");
|
||||||
return CoreTokenCheck::Indeterminate;
|
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
||||||
};
|
Ok((status, _)) => classify_core_token_status(status),
|
||||||
match client.user_get_current().await {
|
|
||||||
Ok(_) => CoreTokenCheck::Valid,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let outcome = classify_core_token_error(&e);
|
tracing::debug!(
|
||||||
if outcome == CoreTokenCheck::Indeterminate {
|
error = %e,
|
||||||
tracing::debug!(
|
"forge: core-token probe could not reach forge; treating as indeterminate"
|
||||||
error = %e,
|
);
|
||||||
"forge: core-token probe inconclusive (unreachable / unexpected response); \
|
CoreTokenCheck::Indeterminate
|
||||||
treating as indeterminate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
outcome
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -553,42 +484,37 @@ pub fn core_token() -> Option<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{CoreTokenCheck, classify_core_token_error};
|
use super::{CoreTokenCheck, classify_core_token_status};
|
||||||
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
fn api_err(kind: ApiErrorKind) -> ForgejoError {
|
#[test]
|
||||||
ForgejoError::ApiError(ApiError {
|
fn success_statuses_are_valid() {
|
||||||
message: None,
|
assert_eq!(
|
||||||
kind,
|
classify_core_token_status(StatusCode::OK),
|
||||||
})
|
CoreTokenCheck::Valid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(StatusCode::NO_CONTENT),
|
||||||
|
CoreTokenCheck::Valid
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auth_rejection_errors_are_invalid() {
|
fn auth_rejection_statuses_are_invalid() {
|
||||||
// The whole point: a stale token (forge rebuilt out from under it)
|
// The whole point: a stale token (forge rebuilt out from under it)
|
||||||
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)),
|
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
||||||
CoreTokenCheck::Invalid
|
CoreTokenCheck::Invalid
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_error(&api_err(ApiErrorKind::Forbidden)),
|
classify_core_token_status(StatusCode::FORBIDDEN),
|
||||||
CoreTokenCheck::Invalid
|
CoreTokenCheck::Invalid
|
||||||
);
|
);
|
||||||
// Defensive: the same statuses arriving as bare status codes
|
|
||||||
// (endpoint spec didn't list them) must classify identically.
|
|
||||||
for s in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
|
|
||||||
assert_eq!(
|
|
||||||
classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)),
|
|
||||||
CoreTokenCheck::Invalid,
|
|
||||||
"status {s} should be invalid"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transient_and_unexpected_errors_are_indeterminate() {
|
fn transient_and_unexpected_statuses_are_indeterminate() {
|
||||||
// Never re-mint on a transient — minting needs the forge too, and
|
// Never re-mint on a transient — minting needs the forge too, and
|
||||||
// churning tokens on a blip is worse than keeping the existing one.
|
// churning tokens on a blip is worse than keeping the existing one.
|
||||||
for s in [
|
for s in [
|
||||||
|
|
@ -599,18 +525,10 @@ mod tests {
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)),
|
classify_core_token_status(s),
|
||||||
CoreTokenCheck::Indeterminate,
|
CoreTokenCheck::Indeterminate,
|
||||||
"status {s} should be indeterminate"
|
"status {s} should be indeterminate"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
|
||||||
classify_core_token_error(&api_err(ApiErrorKind::NotFound { errors: None })),
|
|
||||||
CoreTokenCheck::Indeterminate
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_core_token_error(&api_err(ApiErrorKind::Generic)),
|
|
||||||
CoreTokenCheck::Indeterminate
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,7 @@
|
||||||
//! webhook is auto-created by [`ensure_webhook`] at startup. A
|
//! webhook is auto-created by [`ensure_webhook`] at startup. A
|
||||||
//! periodic pull in `main.rs` provides a fallback cadence.
|
//! periodic pull in `main.rs` provides a fallback cadence.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
|
|
||||||
|
|
||||||
pub const ORG: &str = "internal";
|
pub const ORG: &str = "internal";
|
||||||
pub const REPO: &str = "knowledge";
|
pub const REPO: &str = "knowledge";
|
||||||
|
|
@ -148,61 +145,66 @@ async fn seed_readme(core_token: &str) -> Result<()> {
|
||||||
/// Called at startup alongside [`ensure_local_clone`]. No-op when the
|
/// Called at startup alongside [`ensure_local_clone`]. No-op when the
|
||||||
/// core token is absent (forge not yet provisioned).
|
/// core token is absent (forge not yet provisioned).
|
||||||
pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> {
|
pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> {
|
||||||
// The typed client carries no per-request timeout, so each call is
|
|
||||||
// wrapped in one: this runs as a detached startup task, and a forge
|
|
||||||
// that accepts connections but never answers would otherwise hang
|
|
||||||
// it forever (and the hourly pull fallback masks the missing hook).
|
|
||||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
||||||
|
|
||||||
let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge");
|
let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge");
|
||||||
let client = crate::forge::api(core_token)?;
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.context("build reqwest client for webhook setup")?;
|
||||||
|
|
||||||
// List existing hooks — skip creation if ours is already there.
|
// List existing hooks — skip creation if ours is already there.
|
||||||
// Best-effort like the raw-HTTP predecessor: a listing failure
|
let list_url = format!(
|
||||||
// falls through to the create attempt.
|
"{}/api/v1/repos/{ORG}/{REPO}/hooks",
|
||||||
let listed = tokio::time::timeout(HTTP_TIMEOUT, client.repo_list_hooks(ORG, REPO).all())
|
crate::forge::FORGE_HTTP
|
||||||
|
);
|
||||||
|
let resp = client
|
||||||
|
.get(&list_url)
|
||||||
|
.header("Authorization", format!("token {core_token}"))
|
||||||
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::from)
|
.with_context(|| format!("GET {list_url}"))?;
|
||||||
.and_then(|r| r.map_err(anyhow::Error::from));
|
if resp.status().is_success() {
|
||||||
match listed {
|
let hooks: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
|
||||||
Ok(hooks) => {
|
let already_exists = hooks.iter().any(|h| {
|
||||||
let already_exists = hooks.iter().any(|h| {
|
h.get("config")
|
||||||
h.config
|
.and_then(|c| c.get("url"))
|
||||||
.as_ref()
|
.and_then(|u| u.as_str())
|
||||||
.and_then(|c| c.get("url"))
|
== Some(&target_url)
|
||||||
.map(String::as_str)
|
});
|
||||||
== Some(target_url.as_str())
|
if already_exists {
|
||||||
});
|
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
||||||
if already_exists {
|
return Ok(());
|
||||||
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the webhook.
|
// Create the webhook.
|
||||||
let hook = CreateHookOption {
|
let create_url = format!(
|
||||||
active: Some(true),
|
"{}/api/v1/repos/{ORG}/{REPO}/hooks",
|
||||||
authorization_header: None,
|
crate::forge::FORGE_HTTP
|
||||||
branch_filter: None,
|
);
|
||||||
config: CreateHookOptionConfig {
|
let body = serde_json::json!({
|
||||||
content_type: "json".to_owned(),
|
"type": "forgejo",
|
||||||
url: url::Url::parse(&target_url).context("parse webhook target url")?,
|
"config": {
|
||||||
additional: BTreeMap::new(),
|
"url": target_url,
|
||||||
|
"content_type": "json"
|
||||||
},
|
},
|
||||||
events: Some(vec!["push".to_owned()]),
|
"events": ["push"],
|
||||||
r#type: CreateHookOptionType::Forgejo,
|
"active": true
|
||||||
};
|
});
|
||||||
tokio::time::timeout(HTTP_TIMEOUT, client.repo_create_hook(ORG, REPO, hook))
|
let resp = client
|
||||||
|
.post(&create_url)
|
||||||
|
.header("Authorization", format!("token {core_token}"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::from)
|
.with_context(|| format!("POST {create_url}"))?;
|
||||||
.and_then(|r| r.map_err(anyhow::Error::from))
|
let status = resp.status();
|
||||||
.with_context(|| format!("create webhook for {ORG}/{REPO}"))?;
|
if status.is_success() {
|
||||||
tracing::info!(%target_url, "knowledge: push webhook created");
|
tracing::info!(%target_url, "knowledge: push webhook created");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("create webhook for {ORG}/{REPO} failed ({status}): {body}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the latest changes in the local clone. Called from the webhook
|
/// Pull the latest changes in the local clone. Called from the webhook
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,6 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
# `sync` = blocking client (hive-forge is a blocking CLI). TLS backend
|
|
||||||
# comes from workspace feature unification on reqwest: the
|
|
||||||
# `rustls-tls-native-roots` feature below applies to forgejo-api's
|
|
||||||
# internal client too (same compiled reqwest), so the hive-CA trust
|
|
||||||
# note keeps holding.
|
|
||||||
forgejo-api = { workspace = true, features = ["sync"] }
|
|
||||||
url.workspace = true
|
|
||||||
time.workspace = true
|
|
||||||
reqwest = { workspace = true, features = [
|
reqwest = { workspace = true, features = [
|
||||||
"json",
|
"json",
|
||||||
# Trust the OS/system CA store (rustls-native-certs), not only the
|
# Trust the OS/system CA store (rustls-native-certs), not only the
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
//! App-level Forgejo client wrapper. Identity is the per-agent token
|
//! Blocking HTTP client for the Forgejo REST API. Identity is the
|
||||||
//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through
|
//! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All
|
||||||
//! the typed [`forgejo_api::sync::Forgejo`] client (exposed via
|
//! verbs go through this client so error surfaces, header set, and
|
||||||
//! [`Client::api`]); a minimal raw `reqwest` client remains for the
|
//! 4xx/5xx body unwrapping stay consistent.
|
||||||
//! few *web-router* routes Forgejo does not serve under `/api/v1/`
|
|
||||||
//! (attachment downloads, Actions artifact/log routes).
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use forgejo_api::{Auth, ForgejoError};
|
|
||||||
use reqwest::blocking::{Client as HttpClient, Response};
|
use reqwest::blocking::{Client as HttpClient, Response};
|
||||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
|
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -21,23 +18,14 @@ const DEFAULT_URL: &str = "http://localhost:3000";
|
||||||
/// take a repo override.
|
/// take a repo override.
|
||||||
const DEFAULT_REPO: &str = "hyperhive/hyperhive";
|
const DEFAULT_REPO: &str = "hyperhive/hyperhive";
|
||||||
|
|
||||||
/// Forgejo client pair: the typed `/api/v1` client plus a raw
|
/// Blocking Forgejo API client. Cheap to clone (wraps an
|
||||||
/// `reqwest` client for web-router-only routes.
|
/// `Arc<reqwest::Client>` internally).
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
/// Typed Forgejo REST client — every `/api/v1` call goes through
|
http: HttpClient,
|
||||||
/// this (see [`Client::api`]).
|
|
||||||
api: forgejo_api::sync::Forgejo,
|
|
||||||
/// Raw HTTP client for the routes Forgejo serves only through its
|
|
||||||
/// web router, NOT under `/api/v1/` (so `forgejo-api` has no
|
|
||||||
/// method for them): attachment downloads at `/attachments/<uuid>`,
|
|
||||||
/// Actions artifact zips, the run-view log streamer, and the
|
|
||||||
/// persisted-log download. Carries the same `Authorization: token`
|
|
||||||
/// header the typed client sends.
|
|
||||||
web: HttpClient,
|
|
||||||
base: String,
|
base: String,
|
||||||
/// Per-agent forge token. Kept so verbs that shell out to `git`
|
/// Per-agent forge token. Kept alongside the pre-built auth header
|
||||||
/// (e.g. `clone`) can assemble an authenticated push URL without
|
/// so verbs that shell out to `git` (e.g. `clone`) can assemble
|
||||||
/// re-reading the token file.
|
/// an authenticated push URL without re-reading the token file.
|
||||||
token: String,
|
token: String,
|
||||||
/// Default repo used when a verb doesn't carry an explicit
|
/// Default repo used when a verb doesn't carry an explicit
|
||||||
/// `[repo]` override.
|
/// `[repo]` override.
|
||||||
|
|
@ -61,24 +49,19 @@ impl Client {
|
||||||
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
|
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
|
||||||
let token = read_token().context("read forge-token")?;
|
let token = read_token().context("read forge-token")?;
|
||||||
|
|
||||||
let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?;
|
|
||||||
let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url)
|
|
||||||
.context("build forgejo client")?;
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
let auth = format!("token {token}");
|
let auth = format!("token {token}");
|
||||||
let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?;
|
let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?;
|
||||||
auth_val.set_sensitive(true);
|
auth_val.set_sensitive(true);
|
||||||
headers.insert(AUTHORIZATION, auth_val);
|
headers.insert(AUTHORIZATION, auth_val);
|
||||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||||
let web = HttpClient::builder()
|
|
||||||
|
let http = HttpClient::builder()
|
||||||
.default_headers(headers)
|
.default_headers(headers)
|
||||||
.build()
|
.build()
|
||||||
.context("build reqwest client")?;
|
.context("build reqwest client")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
api,
|
http,
|
||||||
web,
|
|
||||||
base,
|
base,
|
||||||
token,
|
token,
|
||||||
default_repo,
|
default_repo,
|
||||||
|
|
@ -86,13 +69,6 @@ impl Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed Forgejo REST client. All `/api/v1` traffic goes
|
|
||||||
/// through this.
|
|
||||||
#[must_use]
|
|
||||||
pub fn api(&self) -> &forgejo_api::sync::Forgejo {
|
|
||||||
&self.api
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assemble an authenticated git URL for `repo` (e.g.
|
/// Assemble an authenticated git URL for `repo` (e.g.
|
||||||
/// `internal/knowledge`) by injecting the agent's forge user +
|
/// `internal/knowledge`) by injecting the agent's forge user +
|
||||||
/// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`.
|
/// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`.
|
||||||
|
|
@ -119,6 +95,11 @@ impl Client {
|
||||||
self.json_mode
|
self.json_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the API base path (`<base>/api/v1`).
|
||||||
|
fn api(&self) -> String {
|
||||||
|
format!("{}/api/v1", self.base)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the active repo. `from_env` already folded the
|
/// Return the active repo. `from_env` already folded the
|
||||||
/// `-r/--repo` override into `default_repo`, so verbs just read
|
/// `-r/--repo` override into `default_repo`, so verbs just read
|
||||||
/// it as-is — no per-verb override plumbing.
|
/// it as-is — no per-verb override plumbing.
|
||||||
|
|
@ -127,10 +108,177 @@ impl Client {
|
||||||
&self.default_repo
|
&self.default_repo
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The active repo split into `(owner, name)` for the typed
|
/// GET `<api>/<path>` and decode JSON.
|
||||||
/// client's per-segment path arguments.
|
pub fn get_json(&self, path: &str) -> Result<Value> {
|
||||||
pub fn owner_repo(&self) -> Result<(&str, &str)> {
|
let url = format!("{}{}", self.api(), path);
|
||||||
split_repo(self.repo())
|
let resp = self.http.get(&url).send().context("GET")?;
|
||||||
|
decode_json(resp, &format!("GET {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET `<api>/<path>` and decode JSON. Returns `None` if the
|
||||||
|
/// server responds with 404 (resource absent rather than an error).
|
||||||
|
pub fn get_json_optional(&self, path: &str) -> Result<Option<Value>> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self.http.get(&url).send().context("GET")?;
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
decode_json(resp, &format!("GET {url}")).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET a paginated list endpoint and concatenate all pages.
|
||||||
|
/// `path` should NOT include `page=` (we own it); other query
|
||||||
|
/// params (`?limit=N&state=open&...`) are preserved. Pages drain
|
||||||
|
/// while the response carries a `Link: rel="next"` header, up to
|
||||||
|
/// `max_pages` (the runaway-loop safety cap). Returns the merged
|
||||||
|
/// array. Used by `lint` for repo-wide queries.
|
||||||
|
pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result<Vec<Value>> {
|
||||||
|
let sep = if path.contains('?') { '&' } else { '?' };
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
for page in 1..=max_pages {
|
||||||
|
let url = format!("{}{}{sep}page={page}", self.api(), path);
|
||||||
|
let resp = self.http.get(&url).send().context("GET")?;
|
||||||
|
let has_next = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LINK)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|s| s.contains("rel=\"next\""));
|
||||||
|
let v = decode_json(resp, &format!("GET {url}"))?;
|
||||||
|
let arr = v.as_array().cloned().unwrap_or_default();
|
||||||
|
let empty = arr.is_empty();
|
||||||
|
merged.extend(arr);
|
||||||
|
if empty || !has_next {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET `<api>/<path>` and return the raw response body as text
|
||||||
|
/// (used by `diff` which fetches a `text/plain` blob).
|
||||||
|
pub fn get_text(&self, path: &str, accept: &str) -> Result<String> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header(ACCEPT, accept)
|
||||||
|
.send()
|
||||||
|
.context("GET")?;
|
||||||
|
decode_text(resp, &format!("GET {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST a JSON body to `<api>/<path>` and decode the response.
|
||||||
|
pub fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.context("POST")?;
|
||||||
|
decode_json(resp, &format!("POST {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH a JSON body to `<api>/<path>` and decode the response.
|
||||||
|
pub fn patch_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.patch(&url)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.context("PATCH")?;
|
||||||
|
decode_json(resp, &format!("PATCH {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT a JSON body to `<api>/<path>` and decode the response.
|
||||||
|
pub fn put_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.put(&url)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.context("PUT")?;
|
||||||
|
decode_json(resp, &format!("PUT {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT a JSON body to `<api>/<path>` for an endpoint that returns
|
||||||
|
/// `204 No Content` (empty body), so there is nothing to decode.
|
||||||
|
/// Used by `repo-add-collaborator` (Forgejo's add-collaborator PUT
|
||||||
|
/// answers 204 on success).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the request fails to send (transport/network
|
||||||
|
/// error) or the server responds with a non-2xx status (the response
|
||||||
|
/// body is included in the error).
|
||||||
|
pub fn put_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.put(&url)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.context("PUT")?;
|
||||||
|
check_status(resp, &format!("PUT {url}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH `<api>/<path>` for an endpoint that returns a 2xx with an
|
||||||
|
/// empty body (nothing to decode). Used to mark a notification thread
|
||||||
|
/// read (`/notifications/threads/{id}` answers `205 Reset Content`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the request fails to send (transport/network
|
||||||
|
/// error) or the server responds with a non-2xx status (the response
|
||||||
|
/// body is included in the error).
|
||||||
|
pub fn patch_no_content(&self, path: &str) -> Result<()> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self.http.patch(&url).send().context("PATCH")?;
|
||||||
|
check_status(resp, &format!("PATCH {url}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST a JSON body to `<api>/<path>` for an endpoint that returns a
|
||||||
|
/// 2xx with an empty body (so there is nothing to decode). Used by
|
||||||
|
/// `pr-merge` — Forgejo's merge endpoint answers `200 OK` with no body
|
||||||
|
/// on success and a non-2xx (e.g. `405`) when the PR is not mergeable.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the request fails to send (transport/network
|
||||||
|
/// error) or the server responds with a non-2xx status (the response
|
||||||
|
/// body is included in the error).
|
||||||
|
pub fn post_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.context("POST")?;
|
||||||
|
check_status(resp, &format!("POST {url}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE `<api>/<path>`. Optional JSON body for endpoints that
|
||||||
|
/// need it (Forgejo's subscription unwatch uses bodyless DELETE).
|
||||||
|
pub fn delete(&self, path: &str, body: Option<&Value>) -> Result<()> {
|
||||||
|
let url = format!("{}{}", self.api(), path);
|
||||||
|
let mut req = self.http.delete(&url);
|
||||||
|
if let Some(b) = body {
|
||||||
|
req = req.header(CONTENT_TYPE, "application/json").json(b);
|
||||||
|
}
|
||||||
|
let resp = req.send().context("DELETE")?;
|
||||||
|
check_status(resp, &format!("DELETE {url}"))?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the full URL for a Forgejo attachment by UUID.
|
/// Build the full URL for a Forgejo attachment by UUID.
|
||||||
|
|
@ -152,9 +300,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST a JSON body to a base-relative *web* path (NOT under
|
/// POST a JSON body to a base-relative *web* path (NOT under
|
||||||
/// `/api/v1/`, so no `forgejo-api` method exists) and decode the
|
/// `/api/v1/`) and decode the JSON response. Used for endpoints
|
||||||
/// JSON response. Used for endpoints Forgejo only serves through
|
/// Forgejo only serves through its web router — e.g. the Actions
|
||||||
/// its web router — e.g. the Actions run-view log streamer at
|
/// run-view log streamer at
|
||||||
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
|
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
|
||||||
/// agent's `Authorization: token` header is sent as usual; the
|
/// agent's `Authorization: token` header is sent as usual; the
|
||||||
/// web router accepts a token-authed doer and, because the handler
|
/// web router accepts a token-authed doer and, because the handler
|
||||||
|
|
@ -164,67 +312,44 @@ impl Client {
|
||||||
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
||||||
let url = self.web_url(path);
|
let url = self.web_url(path);
|
||||||
let resp = self
|
let resp = self
|
||||||
.web
|
.http
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
.json(body)
|
.json(body)
|
||||||
.send()
|
.send()
|
||||||
.context("POST")?;
|
.context("POST")?;
|
||||||
let resp = check_status(resp, &format!("POST {url}"))?;
|
decode_json(resp, &format!("POST {url}"))
|
||||||
resp.json::<Value>()
|
|
||||||
.with_context(|| format!("decode JSON for POST {url}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET a raw (non-API) URL and return the response body as bytes.
|
/// GET a raw (non-API) URL and return the response body as bytes.
|
||||||
/// Stays on the raw `reqwest` client because these are *web-router*
|
/// The client's auth headers are still sent — Forgejo requires them
|
||||||
/// routes (`/attachments/<uuid>`, Actions artifact / persisted-log
|
/// for private attachment downloads. Uses the full URL as-is; the
|
||||||
/// downloads) with no `/api/v1` equivalent. The client's auth
|
/// caller is responsible for constructing it (see `attachment_url`).
|
||||||
/// headers are still sent — Forgejo requires them for private
|
|
||||||
/// attachment downloads. Uses the full URL as-is; the caller is
|
|
||||||
/// responsible for constructing it (see `attachment_url`).
|
|
||||||
pub fn get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
|
pub fn get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
|
||||||
let resp = self.web.get(url).send().context("GET")?;
|
let resp = self.http.get(url).send().context("GET")?;
|
||||||
let resp = check_status(resp, &format!("GET {url}"))?;
|
let resp = check_status(resp, &format!("GET {url}"))?;
|
||||||
resp.bytes()
|
resp.bytes()
|
||||||
.map(|b| b.to_vec())
|
.map(|b| b.to_vec())
|
||||||
.with_context(|| format!("read bytes for GET {url}"))
|
.with_context(|| format!("read bytes for GET {url}"))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Split an `owner/name` repo string into its two path segments for
|
/// POST a multipart file upload, returning the parsed response.
|
||||||
/// the typed client. Errors on anything that isn't exactly
|
/// Used by `attach-issue` / `attach-comment`.
|
||||||
/// `owner/name` with both halves non-empty.
|
pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result<Value> {
|
||||||
pub fn split_repo(repo: &str) -> Result<(&str, &str)> {
|
let url = format!("{}{}", self.api(), path);
|
||||||
match repo.split_once('/') {
|
let form = reqwest::blocking::multipart::Form::new()
|
||||||
Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => {
|
.file("attachment", file)
|
||||||
Ok((owner, name))
|
.with_context(|| format!("read {}", file.display()))?;
|
||||||
}
|
let resp = self
|
||||||
_ => bail!("hive-forge: repo must be of the form owner/name, got {repo:?}"),
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.context("POST")?;
|
||||||
|
decode_json(resp, &format!("POST {url}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when a typed-client error is Forgejo saying 404 (resource
|
|
||||||
/// absent rather than a transport / auth / server failure). Used by
|
|
||||||
/// verbs that treat "not there" as a normal state (e.g.
|
|
||||||
/// `subscription` reads a 404 as "not watching").
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_not_found(err: &ForgejoError) -> bool {
|
|
||||||
match err {
|
|
||||||
ForgejoError::ApiError(e) => {
|
|
||||||
matches!(e.error_kind(), forgejo_api::ApiErrorKind::NotFound { .. })
|
|
||||||
}
|
|
||||||
ForgejoError::UnexpectedStatusCode(code) => *code == reqwest::StatusCode::NOT_FOUND,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a CLI-side `u64` issue/PR/comment number to the `i64` the
|
|
||||||
/// typed client's path arguments use. Numbers past `i64::MAX` don't
|
|
||||||
/// exist on any forge; error instead of wrapping.
|
|
||||||
pub fn index(n: u64) -> Result<i64> {
|
|
||||||
i64::try_from(n).with_context(|| format!("number {n} out of range"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Locate and read the forge token. Falls back to `$PWD/forge-token`
|
/// Locate and read the forge token. Falls back to `$PWD/forge-token`
|
||||||
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
|
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
|
||||||
fn read_token() -> Result<String> {
|
fn read_token() -> Result<String> {
|
||||||
|
|
@ -239,10 +364,9 @@ fn read_token() -> Result<String> {
|
||||||
Ok(raw.trim().to_owned())
|
Ok(raw.trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface non-2xx HTTP responses on the raw web routes as anyhow
|
/// Surface non-2xx HTTP responses as anyhow errors with the response
|
||||||
/// errors with the response body included (matches `curl
|
/// body included (matches `curl --fail-with-body`) — turns
|
||||||
/// --fail-with-body`) — turns silent failures into errors with a
|
/// silent failures into errors with a clear message.
|
||||||
/// clear message.
|
|
||||||
fn check_status(resp: Response, op: &str) -> Result<Response> {
|
fn check_status(resp: Response, op: &str) -> Result<Response> {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
|
|
@ -252,28 +376,13 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
|
||||||
bail!("hive-forge: {op} failed ({status}): {body}");
|
bail!("hive-forge: {op} failed ({status}): {body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn decode_json(resp: Response, op: &str) -> Result<Value> {
|
||||||
mod tests {
|
let resp = check_status(resp, op)?;
|
||||||
use super::split_repo;
|
resp.json::<Value>()
|
||||||
|
.with_context(|| format!("decode JSON for {op}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
fn decode_text(resp: Response, op: &str) -> Result<String> {
|
||||||
fn split_repo_accepts_owner_name() {
|
let resp = check_status(resp, op)?;
|
||||||
assert_eq!(
|
resp.text().with_context(|| format!("decode text for {op}"))
|
||||||
split_repo("hyperhive/hyperhive").unwrap(),
|
|
||||||
("hyperhive", "hyperhive")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
split_repo("internal/knowledge").unwrap(),
|
|
||||||
("internal", "knowledge")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_repo_rejects_malformed() {
|
|
||||||
assert!(split_repo("no-slash").is_err());
|
|
||||||
assert!(split_repo("/name").is_err());
|
|
||||||
assert!(split_repo("owner/").is_err());
|
|
||||||
assert!(split_repo("a/b/c").is_err());
|
|
||||||
assert!(split_repo("").is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
//! `forge cli: agents keep commenting without reading prev comments`.)
|
//! `forge cli: agents keep commenting without reading prev comments`.)
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use forgejo_api::structs::{NotifyGetRepoListQuery, NotifyReadThreadQuery};
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::{Client, index, split_repo};
|
use crate::client::Client;
|
||||||
|
|
||||||
/// Forgejo's per-page notification cap.
|
/// Forgejo's per-page notification cap.
|
||||||
const PAGE_SIZE: u32 = 50;
|
const PAGE_SIZE: u64 = 50;
|
||||||
/// How many pages of unread notifications to scan for the thread.
|
/// How many pages of unread notifications to scan for the thread.
|
||||||
/// Notifications come newest-first and a thread the caller is about to
|
/// Notifications come newest-first and a thread the caller is about to
|
||||||
/// comment on was just active, so it sits near the top; this cap keeps
|
/// comment on was just active, so it sits near the top; this cap keeps
|
||||||
|
|
@ -26,7 +26,7 @@ const PAGE_SIZE: u32 = 50;
|
||||||
/// firehose), at the cost of not detecting a match buried past
|
/// firehose), at the cost of not detecting a match buried past
|
||||||
/// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for
|
/// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for
|
||||||
/// a courtesy guard).
|
/// a courtesy guard).
|
||||||
const MAX_PAGES: u32 = 5;
|
const MAX_PAGES: u64 = 5;
|
||||||
|
|
||||||
/// The notification thread id of an UNREAD notification on
|
/// The notification thread id of an UNREAD notification on
|
||||||
/// `<repo>#<number>`, or `None` when the thread has no unread
|
/// `<repo>#<number>`, or `None` when the thread has no unread
|
||||||
|
|
@ -34,30 +34,24 @@ const MAX_PAGES: u32 = 5;
|
||||||
/// number can't false-match another repo. Errors only on transport /
|
/// number can't false-match another repo. Errors only on transport /
|
||||||
/// non-2xx — callers degrade open on `Err`.
|
/// non-2xx — callers degrade open on `Err`.
|
||||||
pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Option<u64>> {
|
pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Option<u64>> {
|
||||||
let (owner, name) = split_repo(repo)?;
|
|
||||||
for page in 1..=MAX_PAGES {
|
for page in 1..=MAX_PAGES {
|
||||||
let query = NotifyGetRepoListQuery {
|
let v = client.get_json(&format!(
|
||||||
all: Some(false),
|
"/repos/{repo}/notifications?all=false&page={page}&limit={PAGE_SIZE}"
|
||||||
..Default::default()
|
))?;
|
||||||
};
|
let arr = v.as_array().cloned().unwrap_or_default();
|
||||||
let (_, threads) = client
|
let len = arr.len() as u64;
|
||||||
.api()
|
for n in &arr {
|
||||||
.notify_get_repo_list(owner, name, query)
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_SIZE)
|
|
||||||
.send()?;
|
|
||||||
for n in &threads {
|
|
||||||
let subject_url = n
|
let subject_url = n
|
||||||
.subject
|
.get("subject")
|
||||||
.as_ref()
|
.and_then(|s| s.get("url"))
|
||||||
.and_then(|s| s.url.as_ref())
|
.and_then(Value::as_str)
|
||||||
.map_or("", url::Url::as_str);
|
.unwrap_or("");
|
||||||
if subject_matches(subject_url, number) {
|
if subject_matches(subject_url, number) {
|
||||||
return Ok(n.id.and_then(|id| u64::try_from(id).ok()));
|
return Ok(n.get("id").and_then(Value::as_u64));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Last (short) page reached — stop.
|
// Last (short) page reached — stop.
|
||||||
if threads.len() < PAGE_SIZE as usize {
|
if len < PAGE_SIZE {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,11 +62,7 @@ pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Opti
|
||||||
/// result). Clears the unread signal so a subsequent comment isn't
|
/// result). Clears the unread signal so a subsequent comment isn't
|
||||||
/// blocked after the agent has read the thread.
|
/// blocked after the agent has read the thread.
|
||||||
pub fn mark_thread_read(client: &Client, thread_id: u64) -> Result<()> {
|
pub fn mark_thread_read(client: &Client, thread_id: u64) -> Result<()> {
|
||||||
client
|
client.patch_no_content(&format!("/notifications/threads/{thread_id}"))
|
||||||
.api()
|
|
||||||
.notify_read_thread(index(thread_id)?, NotifyReadThreadQuery::default())
|
|
||||||
.send()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reading a thread (`comments` / `view`) is the "I've seen it" signal:
|
/// Reading a thread (`comments` / `view`) is the "I've seen it" signal:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::ListActionRunsQuery;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
|
|
||||||
|
|
@ -45,29 +45,28 @@ pub struct Args {
|
||||||
/// Pages over the REST runs list (newest-first) to find the run whose
|
/// Pages over the REST runs list (newest-first) to find the run whose
|
||||||
/// run-page `html_url` ends in `/runs/<run-number>`, returning its global
|
/// run-page `html_url` ends in `/runs/<run-number>`, returning its global
|
||||||
/// run id — the identifier the web artifact-download route requires.
|
/// run id — the identifier the web artifact-download route requires.
|
||||||
fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result<i64> {
|
fn resolve_run_id(client: &Client, repo: &str, run_number: u64) -> Result<u64> {
|
||||||
const PER_PAGE: u32 = 50;
|
const PER_PAGE: u32 = 50;
|
||||||
const MAX_PAGES: u32 = 40;
|
const MAX_PAGES: u32 = 40;
|
||||||
let (owner, name) = client.owner_repo()?;
|
|
||||||
for page in 1..=MAX_PAGES {
|
for page in 1..=MAX_PAGES {
|
||||||
let body = client
|
let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}");
|
||||||
.api()
|
let body = client.get_json(&path)?;
|
||||||
.list_action_runs(owner, name, ListActionRunsQuery::default())
|
let runs = body
|
||||||
.page(page)
|
.get("workflow_runs")
|
||||||
.page_size(PER_PAGE)
|
.and_then(Value::as_array)
|
||||||
.send()?;
|
.cloned()
|
||||||
let runs = body.workflow_runs.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if runs.is_empty() {
|
if runs.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
for run in &runs {
|
for run in &runs {
|
||||||
let tail = run
|
let tail = run
|
||||||
.html_url
|
.get("html_url")
|
||||||
.as_ref()
|
.and_then(Value::as_str)
|
||||||
.and_then(|u| u.as_str().rsplit('/').next())
|
.and_then(|u| u.rsplit('/').next())
|
||||||
.and_then(|s| s.parse::<u64>().ok());
|
.and_then(|s| s.parse::<u64>().ok());
|
||||||
if tail == Some(run_number)
|
if tail == Some(run_number)
|
||||||
&& let Some(id) = run.id
|
&& let Some(id) = run.get("id").and_then(Value::as_u64)
|
||||||
{
|
{
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::EditIssueOption;
|
use serde_json::{Value, json};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -23,45 +22,37 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let idx = index(args.number)?;
|
let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||||
let current = client.api().issue_get_issue(owner, name, idx).send()?;
|
|
||||||
let mut assignees: Vec<String> = current
|
let mut assignees: Vec<String> = current
|
||||||
.assignees
|
.get("assignees")
|
||||||
.unwrap_or_default()
|
.and_then(Value::as_array)
|
||||||
.into_iter()
|
.map(|a| {
|
||||||
.filter_map(|u| u.login)
|
a.iter()
|
||||||
.collect();
|
.filter_map(|u| u.get("login").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
if args.remove {
|
if args.remove {
|
||||||
assignees.retain(|u| u != &args.user);
|
assignees.retain(|u| u != &args.user);
|
||||||
} else if !assignees.contains(&args.user) {
|
} else if !assignees.contains(&args.user) {
|
||||||
assignees.push(args.user.clone());
|
assignees.push(args.user.clone());
|
||||||
}
|
}
|
||||||
let payload = EditIssueOption {
|
let resp = client.patch_json(
|
||||||
assignee: None,
|
&format!("/repos/{repo}/issues/{}", args.number),
|
||||||
assignees: Some(assignees),
|
&json!({ "assignees": assignees }),
|
||||||
body: None,
|
)?;
|
||||||
due_date: None,
|
|
||||||
milestone: None,
|
|
||||||
r#ref: None,
|
|
||||||
state: None,
|
|
||||||
title: None,
|
|
||||||
unset_due_date: None,
|
|
||||||
updated_at: None,
|
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.issue_edit_issue(owner, name, idx, payload)
|
|
||||||
.send()?;
|
|
||||||
let logins: Vec<&str> = resp
|
let logins: Vec<&str> = resp
|
||||||
.assignees
|
.get("assignees")
|
||||||
.as_deref()
|
.and_then(Value::as_array)
|
||||||
.unwrap_or_default()
|
.map(|a| {
|
||||||
.iter()
|
a.iter()
|
||||||
.filter_map(|u| u.login.as_deref())
|
.filter_map(|u| u.get("login").and_then(Value::as_str))
|
||||||
.collect();
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"number": resp.number,
|
"number": resp.get("number"),
|
||||||
"assignees": logins,
|
"assignees": logins,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
//! <file>` — upload a file as an attachment. Prints the browser
|
//! <file>` — upload a file as an attachment. Prints the browser
|
||||||
//! download URL.
|
//! download URL.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{
|
use serde_json::Value;
|
||||||
Attachment, IssueCreateIssueAttachmentQuery, IssueCreateIssueCommentAttachmentQuery,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct IssueArgs {
|
pub struct IssueArgs {
|
||||||
|
|
@ -41,16 +39,11 @@ pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
|
||||||
args.file.display()
|
args.file.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let bytes = read_file(&args.file)?;
|
let resp = client.post_multipart_file(
|
||||||
let query = IssueCreateIssueAttachmentQuery {
|
&format!("/repos/{repo}/issues/{}/assets", args.number),
|
||||||
name: file_name(&args.file),
|
&args.file,
|
||||||
updated_at: None,
|
)?;
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.issue_create_issue_attachment(owner, name, index(args.number)?, &bytes, query)
|
|
||||||
.send()?;
|
|
||||||
print_url(&resp);
|
print_url(&resp);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -68,33 +61,17 @@ pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> {
|
||||||
args.file.display()
|
args.file.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let bytes = read_file(&args.file)?;
|
let resp = client.post_multipart_file(
|
||||||
let query = IssueCreateIssueCommentAttachmentQuery {
|
&format!("/repos/{repo}/issues/comments/{}/assets", args.id),
|
||||||
name: file_name(&args.file),
|
&args.file,
|
||||||
updated_at: None,
|
)?;
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.issue_create_issue_comment_attachment(owner, name, index(args.id)?, &bytes, query)
|
|
||||||
.send()?;
|
|
||||||
print_url(&resp);
|
print_url(&resp);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_file(path: &Path) -> Result<Vec<u8>> {
|
fn print_url(v: &Value) {
|
||||||
std::fs::read(path).with_context(|| format!("read {}", path.display()))
|
if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) {
|
||||||
}
|
|
||||||
|
|
||||||
/// The upload's attachment name — the file's basename, matching what
|
|
||||||
/// the old multipart form (which sent the file with its real name)
|
|
||||||
/// made the server record.
|
|
||||||
fn file_name(path: &Path) -> Option<String> {
|
|
||||||
path.file_name().map(|n| n.to_string_lossy().into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_url(v: &Attachment) {
|
|
||||||
if let Some(url) = &v.browser_download_url {
|
|
||||||
println!("{url}");
|
println!("{url}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
|
|
||||||
|
|
@ -12,16 +13,17 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let (_, branches) = client
|
let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?;
|
||||||
.api()
|
let names: Vec<&str> = v
|
||||||
.repo_list_branches(owner, name)
|
.as_array()
|
||||||
.page_size(100)
|
.map(|a| {
|
||||||
.send()?;
|
a.iter()
|
||||||
for branch in &branches {
|
.filter_map(|b| b.get("name").and_then(Value::as_str))
|
||||||
let Some(n) = branch.name.as_deref() else {
|
.collect()
|
||||||
continue;
|
})
|
||||||
};
|
.unwrap_or_default();
|
||||||
|
for n in names {
|
||||||
if args.pattern.as_deref().is_none_or(|p| n.contains(p)) {
|
if args.pattern.as_deref().is_none_or(|p| n.contains(p)) {
|
||||||
println!("{n}");
|
println!("{n}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,16 @@
|
||||||
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
|
//! - `--branch <name>` → dispatches `--workflow` on that branch directly.
|
||||||
//! - `--run <n>` → looks the run up in the Actions runs list (by the
|
//! - `--run <n>` → looks the run up in the Actions runs list (by the
|
||||||
//! `runs/<n>` tail of its `html_url`, same convention as `ci-log` /
|
//! `runs/<n>` tail of its `html_url`, same convention as `ci-log` /
|
||||||
//! `artifact-get`) and dispatches the SAME workflow on the SAME ref the
|
//! `artifact-get`) and dispatches the SAME workflow on the SAME branch the
|
||||||
//! run used (the run record's `prettyref` + `workflow_id`).
|
//! run used.
|
||||||
//!
|
//!
|
||||||
//! Dispatch re-runs the whole workflow, so there is no single-job variant.
|
//! Dispatch re-runs the whole workflow, so there is no single-job variant.
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{ActionRun, DispatchWorkflowOption, ListActionRunsQuery};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -60,12 +60,12 @@ pub struct Args {
|
||||||
///
|
///
|
||||||
/// Returns an error if none of `--pr` / `--run` / `--branch` is given, if a
|
/// Returns an error if none of `--pr` / `--run` / `--branch` is given, if a
|
||||||
/// `--pr` / `--run` handle can't be resolved (unknown PR/run, or a run
|
/// `--pr` / `--run` handle can't be resolved (unknown PR/run, or a run
|
||||||
/// missing its ref), or if the dispatch POST fails (network, or a non-2xx
|
/// missing its branch), or if the dispatch POST fails (network, or a non-2xx
|
||||||
/// such as `404` for an unknown workflow file or branch).
|
/// such as `404` for an unknown workflow file or branch).
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) {
|
let (workflow, branch) = match (args.pr, args.run, args.branch.as_deref()) {
|
||||||
(Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, pr)?),
|
(Some(pr), _, _) => (args.workflow.clone(), branch_for_pr(client, repo, pr)?),
|
||||||
(_, Some(run), _) => resolve_run(client, repo, run, &args.workflow)?,
|
(_, Some(run), _) => resolve_run(client, repo, run, &args.workflow)?,
|
||||||
(_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()),
|
(_, _, Some(branch)) => (args.workflow.clone(), branch.to_string()),
|
||||||
(None, None, None) => {
|
(None, None, None) => {
|
||||||
|
|
@ -73,16 +73,9 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (owner, name) = client.owner_repo()?;
|
let path = format!("/repos/{repo}/actions/workflows/{workflow}/dispatches");
|
||||||
let body = DispatchWorkflowOption {
|
|
||||||
inputs: None,
|
|
||||||
r#ref: branch.clone(),
|
|
||||||
return_run_info: None,
|
|
||||||
};
|
|
||||||
client
|
client
|
||||||
.api()
|
.post_no_content(&path, &json!({ "ref": branch }))
|
||||||
.dispatch_workflow(owner, name, &workflow, body)
|
|
||||||
.send()
|
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"dispatch workflow {workflow} on {branch} ({repo}) — the workflow \
|
"dispatch workflow {workflow} on {branch} ({repo}) — the workflow \
|
||||||
|
|
@ -96,21 +89,19 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
|
|
||||||
/// Resolve a PR's head branch name (`head.ref`) — the branch a same-repo PR
|
/// Resolve a PR's head branch name (`head.ref`) — the branch a same-repo PR
|
||||||
/// pushes to, which is the ref we dispatch the workflow on.
|
/// pushes to, which is the ref we dispatch the workflow on.
|
||||||
fn branch_for_pr(client: &Client, pr: u64) -> Result<String> {
|
fn branch_for_pr(client: &Client, repo: &str, pr: u64) -> Result<String> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?;
|
||||||
let pull = client
|
pull.get("head")
|
||||||
.api()
|
.and_then(|h| h.get("ref"))
|
||||||
.repo_get_pull_request(owner, name, index(pr)?)
|
.and_then(Value::as_str)
|
||||||
.send()?;
|
.map(str::to_string)
|
||||||
pull.head
|
|
||||||
.and_then(|h| h.r#ref)
|
|
||||||
.with_context(|| format!("ci-rerun: PR #{pr} has no head.ref"))
|
.with_context(|| format!("ci-rerun: PR #{pr} has no head.ref"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page the Actions runs list (newest-first) to find the run whose run-page
|
/// Page the Actions runs list (newest-first) to find the run whose run-page
|
||||||
/// `html_url` ends in `/runs/<run-number>`, returning the `(workflow, branch)`
|
/// `html_url` ends in `/runs/<run-number>`, returning the `(workflow, branch)`
|
||||||
/// to dispatch a fresh run of it. `fallback_workflow` is used when the run
|
/// to dispatch a fresh run of it. `fallback_workflow` is used when the run
|
||||||
/// carries no workflow file name.
|
/// carries no workflow `path`.
|
||||||
fn resolve_run(
|
fn resolve_run(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
|
|
@ -119,22 +110,21 @@ fn resolve_run(
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
const PER_PAGE: u32 = 50;
|
const PER_PAGE: u32 = 50;
|
||||||
const MAX_PAGES: u32 = 40;
|
const MAX_PAGES: u32 = 40;
|
||||||
let (owner, name) = client.owner_repo()?;
|
|
||||||
for page in 1..=MAX_PAGES {
|
for page in 1..=MAX_PAGES {
|
||||||
let body = client
|
let path = format!("/repos/{repo}/actions/runs?limit={PER_PAGE}&page={page}");
|
||||||
.api()
|
let body = client.get_json(&path)?;
|
||||||
.list_action_runs(owner, name, ListActionRunsQuery::default())
|
let runs = body
|
||||||
.page(page)
|
.get("workflow_runs")
|
||||||
.page_size(PER_PAGE)
|
.and_then(Value::as_array)
|
||||||
.send()?;
|
.cloned()
|
||||||
let runs = body.workflow_runs.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if runs.is_empty() {
|
if runs.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
for run in &runs {
|
for run in &runs {
|
||||||
if run_number_of(run) == Some(run_number) {
|
if run_number_of(run) == Some(run_number) {
|
||||||
return run_dispatch_target(run, fallback_workflow)
|
return run_dispatch_target(run, fallback_workflow)
|
||||||
.with_context(|| format!("ci-rerun: run #{run_number} has no ref"));
|
.with_context(|| format!("ci-rerun: run #{run_number} has no head_branch"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,24 +133,24 @@ fn resolve_run(
|
||||||
|
|
||||||
/// The per-repo run NUMBER from a run object's `html_url` (`…/runs/<n>` tail),
|
/// The per-repo run NUMBER from a run object's `html_url` (`…/runs/<n>` tail),
|
||||||
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
|
/// matching the `runs/<n>` the UI shows and `pr-status` surfaces.
|
||||||
fn run_number_of(run: &ActionRun) -> Option<u64> {
|
fn run_number_of(run: &Value) -> Option<u64> {
|
||||||
run.html_url
|
run.get("html_url")
|
||||||
.as_ref()
|
.and_then(Value::as_str)
|
||||||
.and_then(|u| u.as_str().rsplit('/').next())
|
.and_then(|u| u.rsplit('/').next())
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the `(workflow-file, ref)` dispatch target out of a run record:
|
/// Pull the `(workflow-file, branch)` dispatch target out of a run object:
|
||||||
/// `prettyref` is the ref the run ran on (the branch name for push /
|
/// `head_branch` is the branch, and the workflow file is the basename of the
|
||||||
/// dispatch runs — PR-event runs carry a `#<n>` pseudo-ref the dispatch
|
/// run's `path` (e.g. `.forgejo/workflows/ci.yml` → `ci.yml`), falling back to
|
||||||
/// endpoint will reject with a clear 404), and `workflow_id` is the
|
/// `fallback_workflow` when the run carries no usable `path`. `None` only when
|
||||||
/// workflow file name (e.g. `ci.yml`), falling back to
|
/// the run has no `head_branch`.
|
||||||
/// `fallback_workflow` when absent. `None` only when the run has no ref.
|
fn run_dispatch_target(run: &Value, fallback_workflow: &str) -> Option<(String, String)> {
|
||||||
fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(String, String)> {
|
let branch = run.get("head_branch").and_then(Value::as_str)?;
|
||||||
let branch = run.prettyref.as_deref().filter(|s| !s.is_empty())?;
|
|
||||||
let workflow = run
|
let workflow = run
|
||||||
.workflow_id
|
.get("path")
|
||||||
.as_deref()
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|p| p.rsplit('/').next())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or(fallback_workflow);
|
.unwrap_or(fallback_workflow);
|
||||||
Some((workflow.to_string(), branch.to_string()))
|
Some((workflow.to_string(), branch.to_string()))
|
||||||
|
|
@ -168,41 +158,28 @@ fn run_dispatch_target(run: &ActionRun, fallback_workflow: &str) -> Option<(Stri
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ActionRun, run_dispatch_target, run_number_of};
|
use super::{run_dispatch_target, run_number_of};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
/// Build a typed run record from an API-shaped JSON fixture. The
|
|
||||||
/// struct's fields are all optional, but the timestamp / URL
|
|
||||||
/// fields deserialize through `with`-modules that require the
|
|
||||||
/// keys to be *present* (as `null`) — fill those in so partial
|
|
||||||
/// fixtures stay terse.
|
|
||||||
fn run_from(mut v: serde_json::Value) -> ActionRun {
|
|
||||||
let obj = v.as_object_mut().unwrap();
|
|
||||||
for key in ["created", "started", "stopped", "updated", "html_url"] {
|
|
||||||
obj.entry(key).or_insert(serde_json::Value::Null);
|
|
||||||
}
|
|
||||||
serde_json::from_value(v).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_run_number_from_html_url() {
|
fn parses_run_number_from_html_url() {
|
||||||
let run = run_from(json!({ "html_url": "http://forge/h/h/actions/runs/750" }));
|
let run = json!({ "html_url": "http://forge/h/h/actions/runs/750" });
|
||||||
assert_eq!(run_number_of(&run), Some(750));
|
assert_eq!(run_number_of(&run), Some(750));
|
||||||
let run = run_from(json!({ "html_url": "https://forge/o/r/actions/runs/42" }));
|
let run = json!({ "html_url": "https://forge/o/r/actions/runs/42" });
|
||||||
assert_eq!(run_number_of(&run), Some(42));
|
assert_eq!(run_number_of(&run), Some(42));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run_number_of(&run_from(json!({ "html_url": "http://forge/o/r/x" }))),
|
run_number_of(&json!({ "html_url": "http://forge/o/r/x" })),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(run_number_of(&run_from(json!({}))), None);
|
assert_eq!(run_number_of(&json!({})), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_workflow_and_branch() {
|
fn extracts_workflow_and_branch() {
|
||||||
let run = run_from(json!({
|
let run = json!({
|
||||||
"prettyref": "atlas/foo",
|
"head_branch": "atlas/foo",
|
||||||
"workflow_id": "ci.yml",
|
"path": ".forgejo/workflows/ci.yml",
|
||||||
}));
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run_dispatch_target(&run, "fallback.yml"),
|
run_dispatch_target(&run, "fallback.yml"),
|
||||||
Some(("ci.yml".to_string(), "atlas/foo".to_string()))
|
Some(("ci.yml".to_string(), "atlas/foo".to_string()))
|
||||||
|
|
@ -210,8 +187,8 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_default_workflow_without_file() {
|
fn falls_back_to_default_workflow_without_path() {
|
||||||
let run = run_from(json!({ "prettyref": "b" }));
|
let run = json!({ "head_branch": "b" });
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run_dispatch_target(&run, "fallback.yml"),
|
run_dispatch_target(&run, "fallback.yml"),
|
||||||
Some(("fallback.yml".to_string(), "b".to_string()))
|
Some(("fallback.yml".to_string(), "b".to_string()))
|
||||||
|
|
@ -219,7 +196,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_ref_means_no_target() {
|
fn no_branch_means_no_target() {
|
||||||
assert_eq!(run_dispatch_target(&run_from(json!({})), "ci.yml"), None);
|
assert_eq!(run_dispatch_target(&json!({}), "ci.yml"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -14,31 +14,13 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let resp = client
|
let resp = client.patch_json(
|
||||||
.api()
|
&format!("/repos/{repo}/issues/{}", args.number),
|
||||||
.issue_edit_issue(owner, name, index(args.number)?, state_edit("closed"))
|
&json!({ "state": "closed" }),
|
||||||
.send()?;
|
)?;
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"number": resp.number,
|
"number": resp.get("number"),
|
||||||
"state": resp.state,
|
"state": resp.get("state"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An `EditIssueOption` that only sets `state` — shared by `close` /
|
|
||||||
/// `reopen` (the null fields ride along and Forgejo treats them as
|
|
||||||
/// "leave unchanged").
|
|
||||||
pub(crate) fn state_edit(state: &str) -> forgejo_api::structs::EditIssueOption {
|
|
||||||
forgejo_api::structs::EditIssueOption {
|
|
||||||
assignee: None,
|
|
||||||
assignees: None,
|
|
||||||
body: None,
|
|
||||||
due_date: None,
|
|
||||||
milestone: None,
|
|
||||||
r#ref: None,
|
|
||||||
state: Some(state.to_owned()),
|
|
||||||
title: None,
|
|
||||||
unset_due_date: None,
|
|
||||||
updated_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,10 @@
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::CreateIssueCommentOption;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::notify;
|
use crate::notify;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
|
|
@ -64,21 +63,12 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (owner, name) = client.owner_repo()?;
|
let resp = client.post_json(
|
||||||
let resp = client
|
&format!("/repos/{repo}/issues/{}/comments", args.number),
|
||||||
.api()
|
&json!({ "body": body }),
|
||||||
.issue_create_comment(
|
)?;
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
index(args.number)?,
|
|
||||||
CreateIssueCommentOption {
|
|
||||||
body,
|
|
||||||
updated_at: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"id": resp.id,
|
"id": resp.get("id"),
|
||||||
"url": resp.html_url,
|
"url": resp.get("html_url"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
//! `comment-edit <id> [body sources] [repo]` — edit an existing
|
//! `comment-edit <id> [body sources] [repo]` — edit an existing
|
||||||
//! comment by id.
|
//! comment by id.
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::EditIssueCommentOption;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -34,26 +33,14 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
args.body_file.as_deref(),
|
args.body_file.as_deref(),
|
||||||
"comment-edit",
|
"comment-edit",
|
||||||
)?;
|
)?;
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let Some(resp) = client
|
let resp = client.patch_json(
|
||||||
.api()
|
&format!("/repos/{repo}/issues/comments/{}", args.id),
|
||||||
.issue_edit_comment(
|
&json!({ "body": body }),
|
||||||
owner,
|
)?;
|
||||||
name,
|
|
||||||
index(args.id)?,
|
|
||||||
EditIssueCommentOption {
|
|
||||||
body,
|
|
||||||
updated_at: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.send()?
|
|
||||||
else {
|
|
||||||
// Forgejo answers 204 (no content) when the edit was a no-op.
|
|
||||||
bail!("hive-forge comment-edit: comment {} not updated", args.id);
|
|
||||||
};
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"id": resp.id,
|
"id": resp.get("id"),
|
||||||
"user": resp.user.as_ref().and_then(|u| u.login.as_deref()),
|
"user": resp.get("user").and_then(|u| u.get("login")),
|
||||||
"url": resp.html_url,
|
"url": resp.get("html_url"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
//! `comment-show <id>` — print the body (or full JSON envelope
|
//! `comment-show <id>` — print the body (or full JSON envelope
|
||||||
//! when `--json` is set globally) of a single comment by id.
|
//! when `--json` is set globally) of a single comment by id.
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -15,26 +15,20 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let Some(c) = client
|
let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?;
|
||||||
.api()
|
|
||||||
.issue_get_comment(owner, name, index(args.id)?)
|
|
||||||
.send()?
|
|
||||||
else {
|
|
||||||
bail!("hive-forge comment-show: comment {} not found", args.id);
|
|
||||||
};
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"id": c.id,
|
"id": v.get("id"),
|
||||||
"user": c.user.as_ref().and_then(|u| u.login.as_deref()),
|
"user": v.get("user").and_then(|u| u.get("login")),
|
||||||
"created_at": rfc3339(c.created_at),
|
"created_at": v.get("created_at"),
|
||||||
"updated_at": rfc3339(c.updated_at),
|
"updated_at": v.get("updated_at"),
|
||||||
"body": c.body,
|
"body": v.get("body"),
|
||||||
"url": c.html_url,
|
"url": v.get("html_url"),
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
} else {
|
} else {
|
||||||
let body = c.body.as_deref().unwrap_or("");
|
let body = v.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
println!("{body}");
|
println!("{body}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,11 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::IssueGetCommentsQuery;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::notify;
|
use crate::notify;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
/// Forgejo's per-page comment cap. The API caps `limit` at 50 even
|
/// Forgejo's per-page comment cap. The API caps `limit` at 50 even
|
||||||
/// if a higher value is requested; pin it explicitly so the math
|
/// if a higher value is requested; pin it explicitly so the math
|
||||||
|
|
@ -60,12 +59,12 @@ pub struct Args {
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let thread = match args.tail {
|
let thread = match args.tail {
|
||||||
Some(n) => fetch_tail(client, args.number, n)?,
|
Some(n) => fetch_tail(client, repo, args.number, n)?,
|
||||||
None => fetch_head(client, args.number, args.limit)?,
|
None => fetch_head(client, repo, args.number, args.limit)?,
|
||||||
};
|
};
|
||||||
// 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 comments = merge_chronological(thread, fetch_review_bodies(client, repo, 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);
|
||||||
|
|
@ -107,16 +106,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a typed comment page back to the JSON `Value` shape the
|
|
||||||
/// merge + render pipeline works on (the structs serialize to the API
|
|
||||||
/// wire shape, so downstream field access is unchanged).
|
|
||||||
fn to_values<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
|
|
||||||
items
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| serde_json::to_value(&c).map_err(Into::into))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a PR's review *bodies* and normalise them to the comment
|
/// Fetch a PR's review *bodies* and normalise them to the comment
|
||||||
/// shape so they merge alongside issue-thread comments.
|
/// shape so they merge alongside issue-thread comments.
|
||||||
///
|
///
|
||||||
|
|
@ -130,37 +119,34 @@ fn to_values<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
|
||||||
/// `created_at` is synthesised from the review's `submitted_at` so
|
/// `created_at` is synthesised from the review's `submitted_at` so
|
||||||
/// the chronological merge sorts uniformly; `kind:"review"` + the
|
/// the chronological merge sorts uniformly; `kind:"review"` + the
|
||||||
/// review `state` tag the entry for display.
|
/// review `state` tag the entry for display.
|
||||||
fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
|
fn fetch_review_bodies(client: &Client, repo: &str, number: u64) -> Vec<Value> {
|
||||||
let Ok((owner, name)) = client.owner_repo() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Ok(idx) = index(number) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let reviews = client
|
let reviews = client
|
||||||
.api()
|
.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))
|
||||||
.repo_list_pull_reviews(owner, name, idx)
|
.ok()
|
||||||
.send()
|
.and_then(|v| v.as_array().cloned())
|
||||||
.map(|(_, reviews)| reviews)
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
reviews
|
reviews
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let state = r.state.as_deref().unwrap_or("").to_owned();
|
let state = r.get("state").and_then(Value::as_str).unwrap_or("");
|
||||||
if state == "PENDING" {
|
if state == "PENDING" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if r.body.as_deref().unwrap_or("").trim().is_empty() {
|
if r.get("body")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let submitted = rfc3339(r.submitted_at);
|
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"id": r.id,
|
"id": r.get("id"),
|
||||||
"user": r.user,
|
"user": r.get("user"),
|
||||||
"created_at": submitted,
|
"created_at": r.get("submitted_at"),
|
||||||
"updated_at": submitted,
|
"updated_at": r.get("submitted_at"),
|
||||||
"body": r.body,
|
"body": r.get("body"),
|
||||||
"html_url": r.html_url,
|
"html_url": r.get("html_url"),
|
||||||
"kind": "review",
|
"kind": "review",
|
||||||
"state": state,
|
"state": state,
|
||||||
}))
|
}))
|
||||||
|
|
@ -184,19 +170,11 @@ fn merge_chronological(mut items: Vec<Value>, reviews: Vec<Value>) -> Vec<Value>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the first page's worth of comments (existing behaviour).
|
/// Fetch the first page's worth of comments (existing behaviour).
|
||||||
fn fetch_head(client: &Client, number: u64, limit: u64) -> Result<Vec<Value>> {
|
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let v = client.get_json(&format!(
|
||||||
let (_, comments) = client
|
"/repos/{repo}/issues/{number}/comments?limit={limit}"
|
||||||
.api()
|
))?;
|
||||||
.issue_get_comments(
|
Ok(v.as_array().cloned().unwrap_or_default())
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
index(number)?,
|
|
||||||
IssueGetCommentsQuery::default(),
|
|
||||||
)
|
|
||||||
.page_size(u32::try_from(limit).unwrap_or(u32::MAX))
|
|
||||||
.send()?;
|
|
||||||
to_values(comments)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the last `n` comments on an issue/PR in chronological order.
|
/// Fetch the last `n` comments on an issue/PR in chronological order.
|
||||||
|
|
@ -208,17 +186,16 @@ fn fetch_head(client: &Client, number: u64, limit: u64) -> Result<Vec<Value>> {
|
||||||
/// first to know how many exist, then start paginating from the
|
/// first to know how many exist, then start paginating from the
|
||||||
/// page that contains item `total - n`. Work is bounded by
|
/// page that contains item `total - n`. Work is bounded by
|
||||||
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
|
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
|
||||||
fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
|
#[allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
reason = "the forge `comments` count cast to usize is a small issue-thread length, never anywhere near usize::MAX even on a 32-bit target, so it cannot truncate in practice"
|
||||||
|
)]
|
||||||
|
fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<Value>> {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
|
||||||
let idx = index(number)?;
|
let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||||
let issue = client.api().issue_get_issue(owner, name, idx).send()?;
|
|
||||||
let total = issue
|
|
||||||
.comments
|
|
||||||
.and_then(|c| usize::try_from(c).ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
@ -233,19 +210,17 @@ fn fetch_tail(client: &Client, number: u64, n: usize) -> Result<Vec<Value>> {
|
||||||
let last_page = (total - 1) / page_size + 1;
|
let last_page = (total - 1) / page_size + 1;
|
||||||
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
|
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
|
||||||
for page in start_page..=last_page {
|
for page in start_page..=last_page {
|
||||||
let (_, arr) = client
|
let v = client.get_json(&format!(
|
||||||
.api()
|
"/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}"
|
||||||
.issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default())
|
))?;
|
||||||
.page(u32::try_from(page).unwrap_or(u32::MAX))
|
let arr = v.as_array().cloned().unwrap_or_default();
|
||||||
.page_size(u32::try_from(PAGE_SIZE).unwrap_or(u32::MAX))
|
|
||||||
.send()?;
|
|
||||||
if arr.is_empty() {
|
if arr.is_empty() {
|
||||||
// Page came back empty — either we miscounted (comments
|
// Page came back empty — either we miscounted (comments
|
||||||
// deleted between the issue GET and now) or upstream's
|
// deleted between the issue GET and now) or upstream's
|
||||||
// playing tricks. Stop rather than spin.
|
// playing tricks. Stop rather than spin.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
merged.extend(to_values(arr)?);
|
merged.extend(arr);
|
||||||
}
|
}
|
||||||
// The first fetched page contains items from `start_page` × 50
|
// The first fetched page contains items from `start_page` × 50
|
||||||
// back; we overshoot by `start_idx % 50` items. Slice the tail
|
// back; we overshoot by `start_idx % 50` items. Slice the tail
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,8 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery;
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -32,17 +31,11 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let diff = client
|
let diff = client.get_text(
|
||||||
.api()
|
&format!("/repos/{repo}/pulls/{}.diff", args.number),
|
||||||
.repo_download_pull_diff_or_patch(
|
"text/plain",
|
||||||
owner,
|
)?;
|
||||||
name,
|
|
||||||
index(args.number)?,
|
|
||||||
"diff",
|
|
||||||
RepoDownloadPullDiffOrPatchQuery::default(),
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
let out = if args.full {
|
let out = if args.full {
|
||||||
diff
|
diff
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -14,33 +14,22 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let issue = client
|
let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||||
.api()
|
|
||||||
.issue_get_issue(owner, name, index(args.number)?)
|
|
||||||
.send()?;
|
|
||||||
let assignees: Vec<&str> = issue
|
|
||||||
.assignees
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|u| u.login.as_deref())
|
|
||||||
.collect();
|
|
||||||
let labels: Vec<&str> = issue
|
|
||||||
.labels
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|l| l.name.as_deref())
|
|
||||||
.collect();
|
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": issue.number,
|
"number": v.get("number"),
|
||||||
"title": issue.title,
|
"title": v.get("title"),
|
||||||
"state": issue.state,
|
"state": v.get("state"),
|
||||||
"user": issue.user.as_ref().and_then(|u| u.login.as_deref()),
|
"user": v.get("user").and_then(|u| u.get("login")),
|
||||||
"assignees": assignees,
|
"assignees": v.get("assignees")
|
||||||
"labels": labels,
|
.and_then(Value::as_array)
|
||||||
"body": issue.body,
|
.map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"labels": v.get("labels")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"body": v.get("body"),
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::CreateIssueOption;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
|
|
@ -32,23 +32,13 @@ pub struct Args {
|
||||||
/// I/O error from writing the issue URL to stdout.
|
/// I/O error from writing the issue URL to stdout.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let payload = CreateIssueOption {
|
let mut payload = json!({ "title": args.title, "body": body });
|
||||||
assignee: None,
|
if let Some(a) = args.assignee {
|
||||||
assignees: args.assignee.map(|a| vec![a]),
|
payload["assignees"] = json!([a]);
|
||||||
body: Some(body),
|
}
|
||||||
closed: None,
|
let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?;
|
||||||
due_date: None,
|
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||||
labels: None,
|
|
||||||
milestone: None,
|
|
||||||
r#ref: None,
|
|
||||||
title: args.title,
|
|
||||||
};
|
|
||||||
let issue = client
|
|
||||||
.api()
|
|
||||||
.issue_create_issue(owner, name, payload)
|
|
||||||
.send()?;
|
|
||||||
if let Some(url) = issue.html_url {
|
|
||||||
println!("{url}");
|
println!("{url}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Args as ClapArgs, ValueEnum};
|
use clap::{Args as ClapArgs, ValueEnum};
|
||||||
use forgejo_api::structs::EditIssueOption;
|
use serde_json::{Map, Value, json};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(Copy, Clone, ValueEnum)]
|
#[derive(Copy, Clone, ValueEnum)]
|
||||||
|
|
@ -56,9 +55,7 @@ pub struct Args {
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
// Body is partial: only update the body field if a source was
|
// Body is partial: only update the body field if a source was
|
||||||
// actually given. Piped stdin without --body/--body-file leaves
|
// actually given. Piped stdin without --body/--body-file leaves
|
||||||
// body alone (the partial-update contract). Absent fields ride as
|
// body alone (the partial-update contract).
|
||||||
// JSON `null`, which Forgejo's partial-update binding treats as
|
|
||||||
// "leave unchanged".
|
|
||||||
let body_explicit = args.body.is_some() || args.body_file.is_some();
|
let body_explicit = args.body.is_some() || args.body_file.is_some();
|
||||||
let body = if body_explicit {
|
let body = if body_explicit {
|
||||||
body::resolve(args.body.as_deref(), args.body_file.as_deref())?
|
body::resolve(args.body.as_deref(), args.body_file.as_deref())?
|
||||||
|
|
@ -66,28 +63,29 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = EditIssueOption {
|
let mut payload = Map::new();
|
||||||
assignee: None,
|
if let Some(t) = args.title {
|
||||||
assignees: None,
|
payload.insert("title".into(), Value::String(t));
|
||||||
body,
|
}
|
||||||
due_date: None,
|
if let Some(b) = body {
|
||||||
milestone: args.milestone.map(index).transpose()?,
|
payload.insert("body".into(), Value::String(b));
|
||||||
r#ref: None,
|
}
|
||||||
state: args.state.map(|s| s.as_str().to_owned()),
|
if let Some(s) = args.state {
|
||||||
title: args.title,
|
payload.insert("state".into(), Value::String(s.as_str().to_owned()));
|
||||||
unset_due_date: None,
|
}
|
||||||
updated_at: None,
|
if let Some(m) = args.milestone {
|
||||||
};
|
payload.insert("milestone".into(), Value::Number(m.into()));
|
||||||
|
}
|
||||||
|
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let resp = client
|
let resp = client.patch_json(
|
||||||
.api()
|
&format!("/repos/{repo}/issues/{}", args.number),
|
||||||
.issue_edit_issue(owner, name, index(args.number)?, payload)
|
&Value::Object(payload),
|
||||||
.send()?;
|
)?;
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"number": resp.number,
|
"number": resp.get("number"),
|
||||||
"title": resp.title,
|
"title": resp.get("title"),
|
||||||
"state": resp.state,
|
"state": resp.get("state"),
|
||||||
"milestone": resp.milestone.as_ref().and_then(|m| m.title.as_deref()),
|
"milestone": resp.get("milestone").and_then(|m| m.get("title")),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::{Args as ClapArgs, Subcommand};
|
use clap::{Args as ClapArgs, Subcommand};
|
||||||
use forgejo_api::structs::{DeleteLabelsOption, IssueLabelsOption, IssueListLabelsQuery, Label};
|
use serde_json::{Value, json};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -34,84 +33,85 @@ enum Action {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let idx = index(args.number)?;
|
|
||||||
match args.action.unwrap_or(Action::List) {
|
match args.action.unwrap_or(Action::List) {
|
||||||
Action::List => {
|
Action::List => {
|
||||||
let labels = client.api().issue_get_labels(owner, name, idx).send()?;
|
let labels =
|
||||||
|
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
|
||||||
print_label_names(&labels);
|
print_label_names(&labels);
|
||||||
}
|
}
|
||||||
Action::Add { labels } => {
|
Action::Add { labels } => {
|
||||||
if labels.is_empty() {
|
if labels.is_empty() {
|
||||||
bail!("hive-forge labels add: pass at least one label name");
|
bail!("hive-forge labels add: pass at least one label name");
|
||||||
}
|
}
|
||||||
let all = repo_labels(client)?;
|
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?;
|
||||||
let ids: Vec<serde_json::Value> = resolve_ids(&all, &labels)
|
let ids = resolve_ids(&all, &labels);
|
||||||
.into_iter()
|
let resp = client.post_json(
|
||||||
.map(|id| json!(id))
|
&format!("/repos/{repo}/issues/{}/labels", args.number),
|
||||||
.collect();
|
&json!({ "labels": ids }),
|
||||||
let resp = client
|
)?;
|
||||||
.api()
|
|
||||||
.issue_add_label(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
idx,
|
|
||||||
IssueLabelsOption {
|
|
||||||
labels: Some(ids),
|
|
||||||
updated_at: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
print_label_names(&resp);
|
print_label_names(&resp);
|
||||||
}
|
}
|
||||||
Action::Remove { labels } => {
|
Action::Remove { labels } => {
|
||||||
if labels.is_empty() {
|
if labels.is_empty() {
|
||||||
bail!("hive-forge labels remove: pass at least one label name");
|
bail!("hive-forge labels remove: pass at least one label name");
|
||||||
}
|
}
|
||||||
let all = repo_labels(client)?;
|
let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?;
|
||||||
for label in &labels {
|
for name in &labels {
|
||||||
if let Some(id) = lookup_id(&all, label) {
|
if let Some(id) = lookup_id(&all, name) {
|
||||||
let _ = client
|
let _ = client.delete(
|
||||||
.api()
|
&format!("/repos/{repo}/issues/{}/labels/{id}", args.number),
|
||||||
.issue_remove_label(
|
None,
|
||||||
owner,
|
);
|
||||||
name,
|
|
||||||
idx,
|
|
||||||
&id.to_string(),
|
|
||||||
DeleteLabelsOption { updated_at: None },
|
|
||||||
)
|
|
||||||
.send();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let labels = client.api().issue_get_labels(owner, name, idx).send()?;
|
let labels =
|
||||||
|
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
|
||||||
print_label_names(&labels);
|
print_label_names(&labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First page (100) of the repo's label set, for name → id resolution.
|
fn resolve_ids(all: &Value, names: &[String]) -> Vec<u64> {
|
||||||
fn repo_labels(client: &Client) -> Result<Vec<Label>> {
|
let Some(arr) = all.as_array() else {
|
||||||
let (owner, name) = client.owner_repo()?;
|
return Vec::new();
|
||||||
let (_, labels) = client
|
};
|
||||||
.api()
|
names
|
||||||
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
|
.iter()
|
||||||
.page_size(100)
|
.filter_map(|n| {
|
||||||
.send()?;
|
arr.iter().find_map(|l| {
|
||||||
Ok(labels)
|
let lname = l.get("name").and_then(Value::as_str)?;
|
||||||
|
if lname == n {
|
||||||
|
l.get("id").and_then(Value::as_u64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_ids(all: &[Label], names: &[String]) -> Vec<i64> {
|
fn lookup_id(all: &Value, name: &str) -> Option<u64> {
|
||||||
names.iter().filter_map(|n| lookup_id(all, n)).collect()
|
let arr = all.as_array()?;
|
||||||
|
arr.iter().find_map(|l| {
|
||||||
|
let lname = l.get("name").and_then(Value::as_str)?;
|
||||||
|
if lname == name {
|
||||||
|
l.get("id").and_then(Value::as_u64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lookup_id(all: &[Label], name: &str) -> Option<i64> {
|
fn print_label_names(v: &Value) {
|
||||||
all.iter()
|
let names: Vec<&str> = v
|
||||||
.find(|l| l.name.as_deref() == Some(name))
|
.as_array()
|
||||||
.and_then(|l| l.id)
|
.map(|a| {
|
||||||
}
|
a.iter()
|
||||||
|
.filter_map(|l| l.get("name").and_then(Value::as_str))
|
||||||
fn print_label_names(labels: &[Label]) {
|
.collect()
|
||||||
let names: Vec<&str> = labels.iter().filter_map(|l| l.name.as_deref()).collect();
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
let _ = print_json(&json!(names));
|
let _ = print_json(&json!(names));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,14 @@
|
||||||
//! - `assignments [--user NAME]`
|
//! - `assignments [--user NAME]`
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||||||
use forgejo_api::structs::{
|
|
||||||
Issue, IssueGetCommentsQuery, IssueListIssuesQuery, IssueListIssuesQueryState,
|
|
||||||
IssueListIssuesQueryType, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
|
||||||
};
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
||||||
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
||||||
|
|
@ -57,14 +53,11 @@ enum Kind {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kind {
|
impl Kind {
|
||||||
/// Forgejo's `type` filter: `issues` / `pulls`, or absent for the
|
fn forgejo_type(self) -> &'static str {
|
||||||
/// both-kinds slice (the forge returns everything when `type` is
|
|
||||||
/// omitted).
|
|
||||||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
|
||||||
match self {
|
match self {
|
||||||
Kind::Issues => Some(IssueListIssuesQueryType::Issues),
|
Kind::Issues => "issues",
|
||||||
Kind::Pulls => Some(IssueListIssuesQueryType::Pulls),
|
Kind::Pulls => "pulls",
|
||||||
Kind::All => None,
|
Kind::All => "all",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,19 +70,11 @@ enum State {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
fn issue_state(self) -> IssueListIssuesQueryState {
|
fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
State::Open => IssueListIssuesQueryState::Open,
|
State::Open => "open",
|
||||||
State::Closed => IssueListIssuesQueryState::Closed,
|
State::Closed => "closed",
|
||||||
State::All => IssueListIssuesQueryState::All,
|
State::All => "all",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pull_state(self) -> RepoListPullRequestsQueryState {
|
|
||||||
match self {
|
|
||||||
State::Open => RepoListPullRequestsQueryState::Open,
|
|
||||||
State::Closed => RepoListPullRequestsQueryState::Closed,
|
|
||||||
State::All => RepoListPullRequestsQueryState::All,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,44 +125,26 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain the repo's issue list (both kinds unless filtered) across
|
|
||||||
/// pages, up to the runaway cap.
|
|
||||||
fn fetch_issues(
|
|
||||||
client: &Client,
|
|
||||||
r#type: Option<IssueListIssuesQueryType>,
|
|
||||||
state: IssueListIssuesQueryState,
|
|
||||||
) -> Result<Vec<Issue>> {
|
|
||||||
let (owner, name) = client.owner_repo()?;
|
|
||||||
let mut items = Vec::new();
|
|
||||||
for page in 1..=MAX_PAGES {
|
|
||||||
let query = IssueListIssuesQuery {
|
|
||||||
state: Some(state),
|
|
||||||
r#type,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let (_, batch) = client
|
|
||||||
.api()
|
|
||||||
.issue_list_issues(owner, name, query)
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_LIMIT)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < PAGE_LIMIT as usize;
|
|
||||||
items.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── unassigned ─────────────────────────
|
// ───────────────────────── unassigned ─────────────────────────
|
||||||
|
|
||||||
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||||||
let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?;
|
let repo = client.repo();
|
||||||
|
let items = client.get_json_all(
|
||||||
|
&format!(
|
||||||
|
"/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}",
|
||||||
|
args.r#type.forgejo_type(),
|
||||||
|
args.state.as_str()
|
||||||
|
),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
let filtered: Vec<Value> = items
|
let filtered: Vec<Value> = items
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter(|it| it.assignees.as_ref().is_none_or(Vec::is_empty))
|
.filter(|it| {
|
||||||
.map(trim_issue)
|
it.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(Vec::is_empty)
|
||||||
|
})
|
||||||
|
.map(trim_item)
|
||||||
.collect();
|
.collect();
|
||||||
emit(client, &filtered, |it| {
|
emit(client, &filtered, |it| {
|
||||||
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
||||||
|
|
@ -187,64 +154,41 @@ fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||||||
// ───────────────────────── no-reviewer ────────────────────────
|
// ───────────────────────── no-reviewer ────────────────────────
|
||||||
|
|
||||||
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
// PR-only: the pulls endpoint doesn't return issues.
|
// PR-only: `/repos/{repo}/pulls` doesn't return issues.
|
||||||
let mut pulls = Vec::new();
|
let pulls = client.get_json_all(
|
||||||
for page in 1..=MAX_PAGES {
|
&format!(
|
||||||
let query = RepoListPullRequestsQuery {
|
"/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}",
|
||||||
state: Some(args.state.pull_state()),
|
args.state.as_str()
|
||||||
..Default::default()
|
),
|
||||||
};
|
MAX_PAGES,
|
||||||
let (_, batch) = client
|
)?;
|
||||||
.api()
|
|
||||||
.repo_list_pull_requests(owner, name, query)
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_LIMIT)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < PAGE_LIMIT as usize;
|
|
||||||
pulls.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let needle = format!("@{}", args.reviewer);
|
let needle = format!("@{}", args.reviewer);
|
||||||
let mut missing: Vec<Value> = Vec::new();
|
let mut missing: Vec<Value> = Vec::new();
|
||||||
for pr in &pulls {
|
for pr in pulls {
|
||||||
let Some(number) = pr.number.filter(|n| *n > 0) else {
|
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if number == 0 {
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
|
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
|
||||||
// that already @reviewer in the description.
|
// that already @reviewer in the description.
|
||||||
if pr.body.as_deref().unwrap_or("").contains(&needle) {
|
let body = pr.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if body.contains(&needle) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Paginate so PRs with >50 comments don't yield false positives
|
// Paginate so PRs with >50 comments don't yield false positives
|
||||||
// (flagged in review). Same 1000-comment ceiling as elsewhere.
|
// (flagged in review). Same 1000-comment ceiling as elsewhere.
|
||||||
let mut mentioned = false;
|
let comments = client.get_json_all(
|
||||||
for page in 1..=MAX_PAGES {
|
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
|
||||||
let (_, comments) = client
|
MAX_PAGES,
|
||||||
.api()
|
)?;
|
||||||
.issue_get_comments(owner, name, number, IssueGetCommentsQuery::default())
|
let mentioned = comments.iter().any(|c| {
|
||||||
.page(page)
|
c.get("body")
|
||||||
.page_size(PAGE_LIMIT)
|
.and_then(Value::as_str)
|
||||||
.send()?;
|
.is_some_and(|body| body.contains(&needle))
|
||||||
let short = comments.len() < PAGE_LIMIT as usize;
|
});
|
||||||
mentioned = comments
|
|
||||||
.iter()
|
|
||||||
.any(|c| c.body.as_deref().is_some_and(|body| body.contains(&needle)));
|
|
||||||
if mentioned || short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !mentioned {
|
if !mentioned {
|
||||||
missing.push(json!({
|
missing.push(trim_item(pr));
|
||||||
"number": pr.number,
|
|
||||||
"title": pr.title,
|
|
||||||
"state": pr.state,
|
|
||||||
"url": pr.html_url,
|
|
||||||
"is_pr": true,
|
|
||||||
"assignees": logins(pr.assignees.as_deref()),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
||||||
|
|
@ -256,64 +200,50 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||||||
if args.days < 0 {
|
if args.days < 0 {
|
||||||
bail!("--days must be non-negative");
|
bail!("--days must be non-negative");
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let mut branches = Vec::new();
|
let branches = client.get_json_all(
|
||||||
for page in 1..=MAX_PAGES {
|
&format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"),
|
||||||
let (_, batch) = client
|
MAX_PAGES,
|
||||||
.api()
|
)?;
|
||||||
.repo_list_branches(owner, name)
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_LIMIT)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < PAGE_LIMIT as usize;
|
|
||||||
branches.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Collect active PR head refs to skip — a branch with an open PR
|
// Collect active PR head refs to skip — a branch with an open PR
|
||||||
// isn't "stale", it's "in review".
|
// isn't "stale", it's "in review".
|
||||||
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
|
let open_pulls = client.get_json_all(
|
||||||
for page in 1..=MAX_PAGES {
|
&format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"),
|
||||||
let query = RepoListPullRequestsQuery {
|
MAX_PAGES,
|
||||||
state: Some(RepoListPullRequestsQueryState::Open),
|
)?;
|
||||||
..Default::default()
|
let active_heads: std::collections::HashSet<String> = open_pulls
|
||||||
};
|
.iter()
|
||||||
let (_, batch) = client
|
.filter_map(|p| {
|
||||||
.api()
|
p.get("head")
|
||||||
.repo_list_pull_requests(owner, name, query)
|
.and_then(|h| h.get("ref"))
|
||||||
.page(page)
|
.and_then(Value::as_str)
|
||||||
.page_size(PAGE_LIMIT)
|
.map(str::to_owned)
|
||||||
.send()?;
|
})
|
||||||
let short = batch.len() < PAGE_LIMIT as usize;
|
.collect();
|
||||||
active_heads.extend(
|
|
||||||
batch
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())),
|
|
||||||
);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let today = OffsetDateTime::now_utc().date();
|
let cutoff_days = today_days_utc().context("compute today")? - args.days;
|
||||||
let mut stale: Vec<Value> = Vec::new();
|
let mut stale: Vec<Value> = Vec::new();
|
||||||
for br in &branches {
|
for br in branches {
|
||||||
let branch_name = br.name.as_deref().unwrap_or("");
|
let name = br.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
if branch_name.is_empty() || active_heads.contains(branch_name) {
|
if name.is_empty() || active_heads.contains(name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(ts) = br.commit.as_ref().and_then(|c| c.timestamp) else {
|
let ts = br
|
||||||
|
.get("commit")
|
||||||
|
.and_then(|c| c.get("timestamp"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let Some(date_str) = ts.get(..10) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Whole days between the commit's calendar date and today —
|
let Some(days) = parse_yyyy_mm_dd_days(date_str) else {
|
||||||
// date-granular, matching the old YYYY-MM-DD prefix math.
|
continue;
|
||||||
let age_days = (today - ts.date()).whole_days();
|
};
|
||||||
if age_days >= args.days {
|
if days <= cutoff_days {
|
||||||
stale.push(json!({
|
stale.push(json!({
|
||||||
"name": branch_name,
|
"name": name,
|
||||||
"last_commit": rfc3339(Some(ts)),
|
"last_commit": ts,
|
||||||
"age_days": age_days,
|
"age_days": (today_days_utc().unwrap_or(days) - days),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -327,14 +257,27 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||||||
// ───────────────────────── assignments ────────────────────────
|
// ───────────────────────── assignments ────────────────────────
|
||||||
|
|
||||||
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||||||
let items = fetch_issues(client, None, IssueListIssuesQueryState::Open)?;
|
let repo = client.repo();
|
||||||
|
let items = client.get_json_all(
|
||||||
|
&format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||||
for it in &items {
|
for it in items {
|
||||||
let assignees = logins(it.assignees.as_deref());
|
let assignees: Vec<String> = it
|
||||||
|
.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.get("login").and_then(Value::as_str))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
if assignees.is_empty() {
|
if assignees.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let slim = trim_issue(it);
|
let slim = trim_item(it);
|
||||||
for u in assignees {
|
for u in assignees {
|
||||||
if args.user.as_deref().is_some_and(|w| w != u) {
|
if args.user.as_deref().is_some_and(|w| w != u) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -374,27 +317,27 @@ fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||||||
|
|
||||||
// ───────────────────────── shared helpers ─────────────────────
|
// ───────────────────────── shared helpers ─────────────────────
|
||||||
|
|
||||||
/// Assignee logins from a typed user list (missing logins dropped).
|
/// Strip a Forgejo issue/PR JSON down to the fields lint output cares
|
||||||
fn logins(users: Option<&[forgejo_api::structs::User]>) -> Vec<String> {
|
|
||||||
users
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|u| u.login.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip a Forgejo issue/PR down to the fields lint output cares
|
|
||||||
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
||||||
fn trim_issue(it: &Issue) -> Value {
|
fn trim_item(it: Value) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"number": it.number,
|
"number": it.get("number"),
|
||||||
"title": it.title,
|
"title": it.get("title"),
|
||||||
"state": it.state,
|
"state": it.get("state"),
|
||||||
"url": it.html_url,
|
"url": it.get("html_url"),
|
||||||
// The unified issues endpoint marks PRs with a `pull_request`
|
// Forgejo's unified /issues endpoint always emits a
|
||||||
// object (absent/null for plain issues).
|
// `pull_request` key — `null` for plain issues, an object
|
||||||
"is_pr": it.pull_request.is_some(),
|
// (with merged/url/etc.) for PRs. Treat any non-null as a PR.
|
||||||
"assignees": logins(it.assignees.as_deref()),
|
"is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()),
|
||||||
|
"assignees": it
|
||||||
|
.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.get("login").cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,3 +375,77 @@ where
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ───
|
||||||
|
|
||||||
|
/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock).
|
||||||
|
fn today_days_utc() -> Result<i64> {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.context("system clock before epoch")?
|
||||||
|
.as_secs();
|
||||||
|
// `as_secs()` returns u64; clamp into i64 (won't overflow until y2554).
|
||||||
|
Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp)
|
||||||
|
/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse
|
||||||
|
/// failure rather than panicking — lint output stays best-effort.
|
||||||
|
fn parse_yyyy_mm_dd_days(stamp: &str) -> Option<i64> {
|
||||||
|
let bytes = stamp.as_bytes();
|
||||||
|
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
|
||||||
|
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
|
||||||
|
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
|
||||||
|
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(days_from_civil(year, month, day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since
|
||||||
|
/// 1970-01-01. Public-domain reference algorithm. Handles negative years.
|
||||||
|
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
|
||||||
|
let y = if month <= 2 { year - 1 } else { year };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = i64::from(y - era * 400); // [0, 399]
|
||||||
|
let m = i64::from(month);
|
||||||
|
let d = i64::from(day);
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
i64::from(era) * 146_097 + doe - 719_468
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epoch_is_day_zero() {
|
||||||
|
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_dates() {
|
||||||
|
// Hinnant reference values
|
||||||
|
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
|
||||||
|
assert_eq!(days_from_civil(2020, 2, 29), 18_321);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_iso_prefix() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"),
|
||||||
|
Some(18_321)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_input() {
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None);
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None);
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,19 @@
|
||||||
//! read-side curl-fallback gap (no boundary concerns —
|
//! read-side curl-fallback gap (no boundary concerns —
|
||||||
//! every agent + the operator queries the issue tracker constantly).
|
//! every agent + the operator queries the issue tracker constantly).
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Args as ClapArgs, ValueEnum};
|
use clap::{Args as ClapArgs, ValueEnum};
|
||||||
use forgejo_api::structs::{
|
|
||||||
IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
|
||||||
};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
/// What kind of items to return. Maps onto Forgejo's `type` query
|
/// What kind of items to return. Matches Forgejo's `type` query
|
||||||
/// parameter: `issues` / `pulls`, or no filter at all for `both`
|
/// parameter values verbatim (`issues` / `pulls` / `all`) so the
|
||||||
/// (Forgejo returns issues + PRs when `type` is absent — same slice
|
/// mapping is one-for-one and a future enum addition upstream
|
||||||
/// the forge UI's "Issues" tab shows without a type filter).
|
/// stays trivially supportable.
|
||||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
#[clap(rename_all = "kebab-case")]
|
#[clap(rename_all = "kebab-case")]
|
||||||
pub enum Kind {
|
pub enum Kind {
|
||||||
|
|
@ -31,16 +30,17 @@ pub enum Kind {
|
||||||
Issue,
|
Issue,
|
||||||
/// Pull requests only.
|
/// Pull requests only.
|
||||||
Pr,
|
Pr,
|
||||||
/// Issues + pull requests (default).
|
/// Issues + pull requests (default; matches the forge UI's
|
||||||
|
/// "Issues" tab when no type filter is applied).
|
||||||
Both,
|
Both,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kind {
|
impl Kind {
|
||||||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
fn api_value(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Issue => Some(IssueListIssuesQueryType::Issues),
|
Self::Issue => "issues",
|
||||||
Self::Pr => Some(IssueListIssuesQueryType::Pulls),
|
Self::Pr => "pulls",
|
||||||
Self::Both => None,
|
Self::Both => "all",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +56,11 @@ pub enum State {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
fn query_state(self) -> IssueListIssuesQueryState {
|
fn api_value(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Open => IssueListIssuesQueryState::Open,
|
Self::Open => "open",
|
||||||
Self::Closed => IssueListIssuesQueryState::Closed,
|
Self::Closed => "closed",
|
||||||
Self::All => IssueListIssuesQueryState::All,
|
Self::All => "all",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,40 +100,54 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let query = IssueListIssuesQuery {
|
let mut path = format!(
|
||||||
state: Some(args.state.query_state()),
|
"/repos/{repo}/issues?type={}&state={}&limit={}&page={}",
|
||||||
// The forge parses `labels` as a comma-separated list of names.
|
args.kind.api_value(),
|
||||||
labels: (!args.labels.is_empty()).then(|| args.labels.join(",")),
|
args.state.api_value(),
|
||||||
q: None,
|
args.limit,
|
||||||
r#type: args.kind.query_type(),
|
args.page
|
||||||
milestones: None,
|
);
|
||||||
since: None,
|
if let Some(u) = args.assignee.as_deref()
|
||||||
before: None,
|
&& !u.is_empty()
|
||||||
created_by: args.author.clone().filter(|s| !s.is_empty()),
|
{
|
||||||
assigned_by: args.assignee.clone().filter(|s| !s.is_empty()),
|
write!(path, "&assigned_by={}", super::pct_encode(u)).unwrap();
|
||||||
mentioned_by: args.mention.clone().filter(|s| !s.is_empty()),
|
|
||||||
sort: None,
|
|
||||||
};
|
|
||||||
let (_, issues) = client
|
|
||||||
.api()
|
|
||||||
.issue_list_issues(owner, name, query)
|
|
||||||
.page(u32::try_from(args.page).unwrap_or(u32::MAX))
|
|
||||||
.page_size(u32::try_from(args.limit).unwrap_or(u32::MAX))
|
|
||||||
.send()?;
|
|
||||||
let count = issues.len() as u64;
|
|
||||||
let items = serde_json::to_value(issues)?;
|
|
||||||
if client.json_mode() {
|
|
||||||
return print_json(&items);
|
|
||||||
}
|
}
|
||||||
for item in items.as_array().into_iter().flatten() {
|
if let Some(u) = args.author.as_deref()
|
||||||
|
&& !u.is_empty()
|
||||||
|
{
|
||||||
|
write!(path, "&created_by={}", super::pct_encode(u)).unwrap();
|
||||||
|
}
|
||||||
|
if let Some(u) = args.mention.as_deref()
|
||||||
|
&& !u.is_empty()
|
||||||
|
{
|
||||||
|
write!(path, "&mentioned_by={}", super::pct_encode(u)).unwrap();
|
||||||
|
}
|
||||||
|
if !args.labels.is_empty() {
|
||||||
|
// Encode each label individually so a comma INSIDE a label
|
||||||
|
// (rare but legal) gets escaped while the field separator
|
||||||
|
// stays a literal comma the forge will parse as N labels.
|
||||||
|
let encoded: Vec<String> = args.labels.iter().map(|l| super::pct_encode(l)).collect();
|
||||||
|
write!(path, "&labels={}", encoded.join(",")).unwrap();
|
||||||
|
}
|
||||||
|
let resp = client.get_json(&path)?;
|
||||||
|
if client.json_mode() {
|
||||||
|
return print_json(&resp);
|
||||||
|
}
|
||||||
|
let Some(items) = resp.as_array() else {
|
||||||
|
// Forge returned something other than an array — most likely
|
||||||
|
// an error envelope; fall back to JSON-dumping so the user
|
||||||
|
// can see what came back.
|
||||||
|
return print_json(&resp);
|
||||||
|
};
|
||||||
|
for item in items {
|
||||||
print_row(item);
|
print_row(item);
|
||||||
}
|
}
|
||||||
// When the returned page is exactly `--limit` items, more pages
|
// When the returned page is exactly `--limit` items, more pages
|
||||||
// may exist. Print a hint to stderr so the caller knows to fetch
|
// may exist. Print a hint to stderr so the caller knows to fetch
|
||||||
// the next page rather than assuming the result is complete. Only
|
// the next page rather than assuming the result is complete. Only
|
||||||
// fires on a full page — a short or empty page signals the end.
|
// fires on a full page — a short or empty page signals the end.
|
||||||
if count == args.limit {
|
if items.len() as u64 == args.limit {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).",
|
"… {} shown (page {}); more results may exist — re-run with --page {} (or raise --limit).",
|
||||||
args.limit,
|
args.limit,
|
||||||
|
|
@ -170,25 +184,19 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kind_query_types_match_forgejo_enum() {
|
fn kind_api_values_match_forgejo_enum() {
|
||||||
// The forge accepts only `issues` / `pulls` for the `type`
|
// The forge accepts only `issues` / `pulls` / `all` for the
|
||||||
// parameter (absent = both) — pin the mapping so a clap rename
|
// `type` parameter — pin the wire mapping so a clap rename
|
||||||
// doesn't silently start returning the wrong slice.
|
// doesn't silently start returning the wrong slice.
|
||||||
assert_eq!(
|
assert_eq!(Kind::Issue.api_value(), "issues");
|
||||||
Kind::Issue.query_type(),
|
assert_eq!(Kind::Pr.api_value(), "pulls");
|
||||||
Some(IssueListIssuesQueryType::Issues)
|
assert_eq!(Kind::Both.api_value(), "all");
|
||||||
);
|
|
||||||
assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls));
|
|
||||||
assert_eq!(Kind::Both.query_type(), None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_query_states_match_forgejo_enum() {
|
fn state_api_values_match_forgejo_enum() {
|
||||||
assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open);
|
assert_eq!(State::Open.api_value(), "open");
|
||||||
assert_eq!(
|
assert_eq!(State::Closed.api_value(), "closed");
|
||||||
State::Closed.query_state(),
|
assert_eq!(State::All.api_value(), "all");
|
||||||
IssueListIssuesQueryState::Closed
|
|
||||||
);
|
|
||||||
assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
//! `milestone list|create|close` — manage milestones. Default action:
|
//! `milestone list|create|close` — manage milestones. Default action:
|
||||||
//! list.
|
//! list.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use clap::{Args as ClapArgs, Subcommand};
|
use clap::{Args as ClapArgs, Subcommand};
|
||||||
use forgejo_api::structs::{
|
|
||||||
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery,
|
|
||||||
};
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::OffsetDateTime;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -43,71 +38,52 @@ enum Action {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
match args.action.unwrap_or(Action::List) {
|
match args.action.unwrap_or(Action::List) {
|
||||||
Action::List => {
|
Action::List => {
|
||||||
let query = IssueGetMilestonesListQuery {
|
let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
|
||||||
state: Some("open".to_owned()),
|
let trimmed: Vec<Value> = v
|
||||||
name: None,
|
.as_array()
|
||||||
};
|
.map(|a| {
|
||||||
let (_, milestones) = client
|
a.iter()
|
||||||
.api()
|
.map(|m| {
|
||||||
.issue_get_milestones_list(owner, name, query)
|
json!({
|
||||||
.page_size(50)
|
"id": m.get("id"),
|
||||||
.send()?;
|
"title": m.get("title"),
|
||||||
let trimmed: Vec<Value> = milestones
|
"open_issues": m.get("open_issues"),
|
||||||
.iter()
|
"closed_issues": m.get("closed_issues"),
|
||||||
.map(|m| {
|
"due_on": m.get("due_on"),
|
||||||
json!({
|
"description": m.get("description"),
|
||||||
"id": m.id,
|
})
|
||||||
"title": m.title,
|
})
|
||||||
"open_issues": m.open_issues,
|
.collect()
|
||||||
"closed_issues": m.closed_issues,
|
|
||||||
"due_on": rfc3339(m.due_on),
|
|
||||||
"description": m.description,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect();
|
.unwrap_or_default();
|
||||||
print_json(&Value::Array(trimmed))
|
print_json(&Value::Array(trimmed))
|
||||||
}
|
}
|
||||||
Action::Create { title, desc, due } => {
|
Action::Create { title, desc, due } => {
|
||||||
let due_on = due
|
let mut payload = json!({ "title": title });
|
||||||
.filter(|s| !s.is_empty())
|
if let Some(d) = desc.filter(|s| !s.is_empty()) {
|
||||||
.map(|d| {
|
payload["description"] = Value::String(d);
|
||||||
OffsetDateTime::parse(&format!("{d}T00:00:00Z"), &Rfc3339)
|
}
|
||||||
.with_context(|| format!("milestone create: bad --due date {d:?}"))
|
if let Some(d) = due.filter(|s| !s.is_empty()) {
|
||||||
})
|
payload["due_on"] = Value::String(format!("{d}T00:00:00Z"));
|
||||||
.transpose()?;
|
}
|
||||||
let payload = CreateMilestoneOption {
|
let resp = client.post_json(&format!("/repos/{repo}/milestones"), &payload)?;
|
||||||
description: desc.filter(|s| !s.is_empty()),
|
|
||||||
due_on,
|
|
||||||
state: None,
|
|
||||||
title: Some(title),
|
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.issue_create_milestone(owner, name, payload)
|
|
||||||
.send()?;
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"id": resp.id,
|
"id": resp.get("id"),
|
||||||
"title": resp.title,
|
"title": resp.get("title"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Action::Close { id } => {
|
Action::Close { id } => {
|
||||||
let payload = EditMilestoneOption {
|
let resp = client.patch_json(
|
||||||
description: None,
|
&format!("/repos/{repo}/milestones/{id}"),
|
||||||
due_on: None,
|
&json!({ "state": "closed" }),
|
||||||
state: Some("closed".to_owned()),
|
)?;
|
||||||
title: None,
|
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.issue_edit_milestone(owner, name, index(id)?, payload)
|
|
||||||
.send()?;
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"id": resp.id,
|
"id": resp.get("id"),
|
||||||
"title": resp.title,
|
"title": resp.get("title"),
|
||||||
"state": resp.state,
|
"state": resp.get("state"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,8 @@ use std::fmt::Write as _;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use time::OffsetDateTime;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
|
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
|
||||||
/// matching the bash script's `| jq` output shape.
|
/// matching the bash script's `| jq` output shape.
|
||||||
|
|
@ -59,15 +57,6 @@ pub(crate) fn print_json(v: &Value) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an optional timestamp as its RFC 3339 string — the shape the
|
|
||||||
/// raw API emitted, so output stays stable across the typed-client
|
|
||||||
/// port. `None` (and the never-in-practice unformattable timestamp)
|
|
||||||
/// map to `None` so callers keep their existing null/placeholder
|
|
||||||
/// handling.
|
|
||||||
pub(crate) fn rfc3339(ts: Option<OffsetDateTime>) -> Option<String> {
|
|
||||||
ts.and_then(|t| t.format(&Rfc3339).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` sub-command
|
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` sub-command
|
||||||
/// validation.
|
/// validation.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|
@ -83,12 +72,9 @@ pub(crate) enum Kind {
|
||||||
/// PRs and marks PRs with a non-null `pull_request` field, so one GET
|
/// PRs and marks PRs with a non-null `pull_request` field, so one GET
|
||||||
/// classifies it. Errors with a "use the other command" message on mismatch.
|
/// classifies it. Errors with a "use the other command" message on mismatch.
|
||||||
pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> {
|
pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let issue = client
|
let v = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
|
||||||
.api()
|
let is_pr = v.get("pull_request").is_some_and(|p| !p.is_null());
|
||||||
.issue_get_issue(owner, name, index(number)?)
|
|
||||||
.send()?;
|
|
||||||
let is_pr = issue.pull_request.is_some();
|
|
||||||
match (expected, is_pr) {
|
match (expected, is_pr) {
|
||||||
(Kind::Pr, false) => {
|
(Kind::Pr, false) => {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|
@ -103,12 +89,12 @@ pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Resul
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of
|
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of
|
||||||
/// characters that show up in the values we splice into *web-route* paths
|
/// characters that show up in the values we splice into request paths —
|
||||||
/// (the typed client encodes its own path segments) — artifact names —
|
/// usernames, label names, artifact names — without pulling in a fresh
|
||||||
/// without pulling in a fresh workspace dep. Unreserved bytes
|
/// workspace dep. Unreserved bytes (`[A-Za-z0-9-._~]`) pass through, so
|
||||||
/// (`[A-Za-z0-9-._~]`) pass through, so the common identifier case is a
|
/// the common identifier case is a no-op; everything else is `%XX`-escaped.
|
||||||
/// no-op; everything else is `%XX`-escaped. Used by `artifact-get` (the
|
/// Shared by `list` (query-string filters) and `artifact-get` (the
|
||||||
/// artifact-name path segment on the web download route).
|
/// artifact-name path segment).
|
||||||
pub(crate) fn pct_encode(s: &str) -> String {
|
pub(crate) fn pct_encode(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
for b in s.bytes() {
|
for b in s.bytes() {
|
||||||
|
|
@ -133,30 +119,17 @@ pub(crate) fn latest_reviews(
|
||||||
repo: &str,
|
repo: &str,
|
||||||
pr: u64,
|
pr: u64,
|
||||||
) -> Result<Vec<(String, String)>> {
|
) -> Result<Vec<(String, String)>> {
|
||||||
let (owner, name) = crate::client::split_repo(repo)?;
|
let reviews = client.get_json_all(&format!("/repos/{repo}/pulls/{pr}/reviews"), 10)?;
|
||||||
let pr = index(pr)?;
|
|
||||||
// Paginate (50/page, 10-page runaway cap — same ceiling the raw
|
|
||||||
// client used) so a heavily re-reviewed PR doesn't truncate.
|
|
||||||
let mut reviews = Vec::new();
|
|
||||||
for page in 1..=10u32 {
|
|
||||||
let (_, batch) = client
|
|
||||||
.api()
|
|
||||||
.repo_list_pull_reviews(owner, name, pr)
|
|
||||||
.page(page)
|
|
||||||
.page_size(50)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < 50;
|
|
||||||
reviews.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut latest: Vec<(String, String)> = Vec::new();
|
let mut latest: Vec<(String, String)> = Vec::new();
|
||||||
for r in &reviews {
|
for r in &reviews {
|
||||||
let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else {
|
let Some(login) = r
|
||||||
|
.get("user")
|
||||||
|
.and_then(|u| u.get("login"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let st = r.state.as_deref().unwrap_or("");
|
let st = r.get("state").and_then(Value::as_str).unwrap_or("");
|
||||||
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
|
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -175,8 +148,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pct_encode_passes_unreserved_through() {
|
fn pct_encode_passes_unreserved_through() {
|
||||||
// Plain artifact names round-trip verbatim — no performance
|
// Usernames + plain label/artifact names round-trip verbatim —
|
||||||
// regression on the common case.
|
// no performance regression on the common case.
|
||||||
assert_eq!(pct_encode("damocles"), "damocles");
|
assert_eq!(pct_encode("damocles"), "damocles");
|
||||||
assert_eq!(pct_encode("area-ops"), "area-ops");
|
assert_eq!(pct_encode("area-ops"), "area-ops");
|
||||||
assert_eq!(pct_encode("area_ops"), "area_ops");
|
assert_eq!(pct_encode("area_ops"), "area_ops");
|
||||||
|
|
@ -185,8 +158,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pct_encode_escapes_reserved() {
|
fn pct_encode_escapes_reserved() {
|
||||||
// `&` / `/` / spaces in any spliced value must escape so they
|
// Forgejo labels can contain spaces ("good first issue" is the
|
||||||
// can't break out of the path/query segment.
|
// canonical example); `&` / `/` in any spliced value must escape
|
||||||
|
// so they can't break out of the path/query segment.
|
||||||
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
|
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
|
||||||
assert_eq!(pct_encode("x&y"), "x%26y");
|
assert_eq!(pct_encode("x&y"), "x%26y");
|
||||||
assert_eq!(pct_encode("a/b"), "a%2Fb");
|
assert_eq!(pct_encode("a/b"), "a%2Fb");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -14,20 +14,17 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let pull = client
|
let v = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?;
|
||||||
.api()
|
|
||||||
.repo_get_pull_request(owner, name, index(args.number)?)
|
|
||||||
.send()?;
|
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": pull.number,
|
"number": v.get("number"),
|
||||||
"title": pull.title,
|
"title": v.get("title"),
|
||||||
"state": pull.state,
|
"state": v.get("state"),
|
||||||
"merged": pull.merged,
|
"merged": v.get("merged"),
|
||||||
"user": pull.user.as_ref().and_then(|u| u.login.as_deref()),
|
"user": v.get("user").and_then(|u| u.get("login")),
|
||||||
"head_sha": pull.head.as_ref().and_then(|h| h.sha.as_deref()),
|
"head_sha": v.get("head").and_then(|h| h.get("sha")),
|
||||||
"head_branch": pull.head.as_ref().and_then(|h| h.label.as_deref()),
|
"head_branch": v.get("head").and_then(|h| h.get("label")),
|
||||||
"base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()),
|
"base_branch": v.get("base").and_then(|b| b.get("label")),
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::PullReviewRequestOptions;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -24,26 +24,17 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let idx = index(args.number)?;
|
let path = format!("/repos/{repo}/pulls/{}/requested_reviewers", args.number);
|
||||||
let body = PullReviewRequestOptions {
|
let body = json!({ "reviewers": [args.user] });
|
||||||
reviewers: Some(vec![args.user.clone()]),
|
|
||||||
team_reviewers: None,
|
|
||||||
};
|
|
||||||
if args.remove {
|
if args.remove {
|
||||||
client
|
client.delete(&path, Some(&body))?;
|
||||||
.api()
|
|
||||||
.repo_delete_pull_review_requests(owner, name, idx, body)
|
|
||||||
.send()?;
|
|
||||||
println!(
|
println!(
|
||||||
"review request withdrawn: {} on #{}",
|
"review request withdrawn: {} on #{}",
|
||||||
args.user, args.number
|
args.user, args.number
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
client
|
client.post_json(&path, &body)?;
|
||||||
.api()
|
|
||||||
.repo_create_pull_review_requests(owner, name, idx, body)
|
|
||||||
.send()?;
|
|
||||||
println!("review requested: {} on #{}", args.user, args.number);
|
println!("review requested: {} on #{}", args.user, args.number);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,15 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{Commit, RepoGetPullRequestCommitsQuery};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
/// Page cap for the commit list. Forgejo serves up to 50 commits per
|
/// Page cap for the commit list. Forgejo serves up to 50 commits per
|
||||||
/// page; 40 pages (2000 commits) is far beyond any real PR.
|
/// page; 40 pages (2000 commits) is far beyond any real PR.
|
||||||
const MAX_PAGES: u32 = 40;
|
const MAX_PAGES: u32 = 40;
|
||||||
|
|
||||||
/// Page size on the commit list endpoint (Forgejo's cap).
|
|
||||||
const PAGE_SIZE: u32 = 50;
|
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// PR number.
|
/// PR number.
|
||||||
|
|
@ -30,35 +26,22 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let idx = index(args.number)?;
|
let commits = client.get_json_all(
|
||||||
let mut commits: Vec<Commit> = Vec::new();
|
&format!("/repos/{repo}/pulls/{}/commits", args.number),
|
||||||
for page in 1..=MAX_PAGES {
|
MAX_PAGES,
|
||||||
let (_, batch) = client
|
)?;
|
||||||
.api()
|
|
||||||
.repo_get_pull_request_commits(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
idx,
|
|
||||||
RepoGetPullRequestCommitsQuery::default(),
|
|
||||||
)
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_SIZE)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < PAGE_SIZE as usize;
|
|
||||||
commits.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let trimmed: Vec<_> = commits
|
let trimmed: Vec<_> = commits
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
|
let commit = c.get("commit");
|
||||||
json!({
|
json!({
|
||||||
"sha": c.sha,
|
"sha": c.get("sha"),
|
||||||
"message": c.commit.as_ref().and_then(|x| x.message.as_deref()),
|
"message": commit.and_then(|x| x.get("message")),
|
||||||
"author_date": rfc3339(c.commit.as_ref().and_then(|x| x.author.as_ref()).and_then(|a| a.date)),
|
"author_date": commit
|
||||||
"author": c.author.as_ref().and_then(|u| u.login.as_deref()),
|
.and_then(|x| x.get("author"))
|
||||||
|
.and_then(|a| a.get("date")),
|
||||||
|
"author": c.get("author").and_then(|u| u.get("login")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{CreatePullRequestOption, EditIssueOption};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -97,27 +97,17 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
if args.push {
|
if args.push {
|
||||||
push_branch(remote, head)?;
|
push_branch(remote, head)?;
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
// Note: Forgejo's CreatePullRequestOption has no `draft` /
|
let payload = json!({
|
||||||
// `allow_maintainer_edit` fields (verified against the instance's
|
"title": args.title,
|
||||||
// swagger) — the raw client used to send both and the server
|
"head": head,
|
||||||
// silently dropped them, so omitting them here changes nothing.
|
"base": args.base,
|
||||||
let payload = CreatePullRequestOption {
|
"body": body,
|
||||||
assignee: None,
|
"draft": args.draft,
|
||||||
assignees: None,
|
"allow_maintainer_edit": true,
|
||||||
base: Some(args.base.clone()),
|
});
|
||||||
body: Some(body),
|
let resp = client.post_json(&format!("/repos/{repo}/pulls"), &payload)?;
|
||||||
due_date: None,
|
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||||
head: Some(head.to_owned()),
|
|
||||||
labels: None,
|
|
||||||
milestone: None,
|
|
||||||
title: Some(args.title.clone()),
|
|
||||||
};
|
|
||||||
let resp = client
|
|
||||||
.api()
|
|
||||||
.repo_create_pull_request(owner, name, payload)
|
|
||||||
.send()?;
|
|
||||||
if let Some(url) = resp.html_url {
|
|
||||||
println!("{url}");
|
println!("{url}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -192,23 +182,12 @@ fn agit_create(client: &Client, args: &Args, body: &str) -> Result<()> {
|
||||||
};
|
};
|
||||||
if deferred_body {
|
if deferred_body {
|
||||||
if let Some(number) = pr_number_from_url(&url) {
|
if let Some(number) = pr_number_from_url(&url) {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let payload = EditIssueOption {
|
|
||||||
assignee: None,
|
|
||||||
assignees: None,
|
|
||||||
body: Some(body.to_owned()),
|
|
||||||
due_date: None,
|
|
||||||
milestone: None,
|
|
||||||
r#ref: None,
|
|
||||||
state: None,
|
|
||||||
title: None,
|
|
||||||
unset_due_date: None,
|
|
||||||
updated_at: None,
|
|
||||||
};
|
|
||||||
client
|
client
|
||||||
.api()
|
.patch_json(
|
||||||
.issue_edit_issue(owner, name, index(number)?, payload)
|
&format!("/repos/{repo}/issues/{number}"),
|
||||||
.send()
|
&json!({ "body": body }),
|
||||||
|
)
|
||||||
.with_context(|| format!("set body on AGit PR #{number}"))?;
|
.with_context(|| format!("set body on AGit PR #{number}"))?;
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::{Args as ClapArgs, ValueEnum};
|
use clap::{Args as ClapArgs, ValueEnum};
|
||||||
use forgejo_api::structs::{
|
use serde_json::{Value, json};
|
||||||
CommitStatusState, MergePullRequestOption, MergePullRequestOptionDo, PullRequest, StateType,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::client::{Client, index, split_repo};
|
use crate::client::Client;
|
||||||
|
|
||||||
/// Merge strategy. Squash is deliberately omitted (hive convention: keep the
|
/// Merge strategy. Squash is deliberately omitted (hive convention: keep the
|
||||||
/// per-commit history, so a squash option isn't exposed).
|
/// per-commit history, so a squash option isn't exposed).
|
||||||
|
|
@ -31,22 +29,13 @@ pub enum Method {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Method {
|
impl Method {
|
||||||
/// The Forgejo `Do` field value for this strategy (used for the
|
/// The Forgejo `Do` field value for this strategy.
|
||||||
/// confirmation message).
|
|
||||||
fn forgejo_do(self) -> &'static str {
|
fn forgejo_do(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Method::Merge => "merge",
|
Method::Merge => "merge",
|
||||||
Method::Rebase => "rebase",
|
Method::Rebase => "rebase",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed `Do` enum variant for the merge request body.
|
|
||||||
fn as_option_do(self) -> MergePullRequestOptionDo {
|
|
||||||
match self {
|
|
||||||
Method::Merge => MergePullRequestOptionDo::Merge,
|
|
||||||
Method::Rebase => MergePullRequestOptionDo::Rebase,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -76,17 +65,12 @@ pub struct Args {
|
||||||
/// be merged).
|
/// be merged).
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let (owner, name) = client.owner_repo()?;
|
let pull = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?;
|
||||||
let idx = index(args.number)?;
|
|
||||||
let pull = client
|
|
||||||
.api()
|
|
||||||
.repo_get_pull_request(owner, name, idx)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
if pull.merged.unwrap_or(false) {
|
if pull.get("merged").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
bail!("pr-merge: PR #{} is already merged", args.number);
|
bail!("pr-merge: PR #{} is already merged", args.number);
|
||||||
}
|
}
|
||||||
if pull.state == Some(StateType::Closed) {
|
if pull.get("state").and_then(Value::as_str) == Some("closed") {
|
||||||
bail!("pr-merge: PR #{} is closed", args.number);
|
bail!("pr-merge: PR #{} is closed", args.number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,20 +78,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
check_ready(client, repo, args.number, &pull)?;
|
check_ready(client, repo, args.number, &pull)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = MergePullRequestOption {
|
let payload = json!({
|
||||||
r#do: args.method.as_option_do(),
|
"Do": args.method.forgejo_do(),
|
||||||
merge_commit_id: None,
|
"delete_branch_after_merge": !args.keep_branch,
|
||||||
merge_message_field: None,
|
"force_merge": args.force,
|
||||||
merge_title_field: None,
|
});
|
||||||
delete_branch_after_merge: Some(!args.keep_branch),
|
client.post_no_content(
|
||||||
force_merge: Some(args.force),
|
&format!("/repos/{repo}/pulls/{}/merge", args.number),
|
||||||
head_commit_id: None,
|
&payload,
|
||||||
merge_when_checks_succeed: None,
|
)?;
|
||||||
};
|
|
||||||
client
|
|
||||||
.api()
|
|
||||||
.repo_merge_pull_request(owner, name, idx, payload)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
let deleted = if args.keep_branch {
|
let deleted = if args.keep_branch {
|
||||||
""
|
""
|
||||||
|
|
@ -126,8 +105,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
/// mergeable, CI must not be red/pending, and no review may request changes.
|
/// mergeable, CI must not be red/pending, and no review may request changes.
|
||||||
/// Bails with an actionable message (pointing at `--force`) on the first
|
/// Bails with an actionable message (pointing at `--force`) on the first
|
||||||
/// failure.
|
/// failure.
|
||||||
fn check_ready(client: &Client, repo: &str, number: u64, pull: &PullRequest) -> Result<()> {
|
fn check_ready(client: &Client, repo: &str, number: u64, pull: &Value) -> Result<()> {
|
||||||
match pull.mergeable {
|
match pull.get("mergeable").and_then(Value::as_bool) {
|
||||||
Some(true) => {}
|
Some(true) => {}
|
||||||
Some(false) => bail!(
|
Some(false) => bail!(
|
||||||
"pr-merge: PR #{number} is not mergeable (conflicts). Rebase it, or pass --force."
|
"pr-merge: PR #{number} is not mergeable (conflicts). Rebase it, or pass --force."
|
||||||
|
|
@ -137,18 +116,20 @@ fn check_ready(client: &Client, repo: &str, number: u64, pull: &PullRequest) ->
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sha) = pull.head.as_ref().and_then(|h| h.sha.as_deref()) {
|
if let Some(sha) = pull
|
||||||
let (owner, name) = split_repo(repo)?;
|
.get("head")
|
||||||
let (_, combined) = client
|
.and_then(|h| h.get("sha"))
|
||||||
.api()
|
.and_then(Value::as_str)
|
||||||
.repo_get_combined_status_by_ref(owner, name, sha)
|
{
|
||||||
.send()?;
|
let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?;
|
||||||
let state = combined.state;
|
let state = combined.get("state").and_then(Value::as_str).unwrap_or("");
|
||||||
let has_statuses = combined.statuses.as_ref().is_some_and(|a| !a.is_empty());
|
let has_statuses = combined
|
||||||
|
.get("statuses")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|a| !a.is_empty());
|
||||||
// An empty status set means no CI is configured — not a blocker.
|
// An empty status set means no CI is configured — not a blocker.
|
||||||
// Anything other than success once CI exists blocks the merge.
|
// Anything other than success once CI exists blocks the merge.
|
||||||
if has_statuses && state != Some(CommitStatusState::Success) {
|
if has_statuses && state != "success" {
|
||||||
let state = super::pr_status::status_state_str(state);
|
|
||||||
bail!(
|
bail!(
|
||||||
"pr-merge: PR #{number} CI is not green (state: {state}). Wait for green, or pass --force."
|
"pr-merge: PR #{number} CI is not green (state: {state}). Wait for green, or pass --force."
|
||||||
);
|
);
|
||||||
|
|
@ -186,20 +167,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_payload_shape() {
|
fn merge_payload_shape() {
|
||||||
// delete-by-default: keep_branch=false → delete_branch_after_merge=true.
|
// delete-by-default: keep_branch=false → delete_branch_after_merge=true.
|
||||||
// Pin the wire shape of the typed body — `Do` casing included.
|
let payload = json!({
|
||||||
let payload = MergePullRequestOption {
|
"Do": Method::Merge.forgejo_do(),
|
||||||
r#do: Method::Merge.as_option_do(),
|
"delete_branch_after_merge": true,
|
||||||
merge_commit_id: None,
|
"force_merge": false,
|
||||||
merge_message_field: None,
|
});
|
||||||
merge_title_field: None,
|
assert_eq!(payload["Do"], "merge");
|
||||||
delete_branch_after_merge: Some(true),
|
assert_eq!(payload["delete_branch_after_merge"], true);
|
||||||
force_merge: Some(false),
|
assert_eq!(payload["force_merge"], false);
|
||||||
head_commit_id: None,
|
|
||||||
merge_when_checks_succeed: None,
|
|
||||||
};
|
|
||||||
let wire = serde_json::to_value(&payload).unwrap();
|
|
||||||
assert_eq!(wire["Do"], "merge");
|
|
||||||
assert_eq!(wire["delete_branch_after_merge"], true);
|
|
||||||
assert_eq!(wire["force_merge"], false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::CreatePullReviewOptions;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -56,69 +55,53 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
/// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`) and print
|
/// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`) and print
|
||||||
/// a compact summary of the created review.
|
/// a compact summary of the created review.
|
||||||
fn submit_review(client: &Client, number: u64, event: &str, body: Option<String>) -> Result<()> {
|
fn submit_review(client: &Client, number: u64, event: &str, body: Option<String>) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let payload = CreatePullReviewOptions {
|
let payload = json!({
|
||||||
body: Some(body.unwrap_or_default()),
|
"event": event,
|
||||||
comments: None,
|
"body": body.unwrap_or_default(),
|
||||||
commit_id: None,
|
});
|
||||||
event: Some(event.to_owned()),
|
let v = client.post_json(&format!("/repos/{repo}/pulls/{number}/reviews"), &payload)?;
|
||||||
};
|
|
||||||
let review = client
|
|
||||||
.api()
|
|
||||||
.repo_create_pull_review(owner, name, index(number)?, payload)
|
|
||||||
.send()?;
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"id": review.id,
|
"id": v.get("id"),
|
||||||
"state": review.state,
|
"state": v.get("state"),
|
||||||
"user": review.user.as_ref().and_then(|u| u.login.as_deref()),
|
"user": v.get("user").and_then(|u| u.get("login")),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch inline diff comments for a single review, serialized back to
|
/// Fetch inline diff comments for a single review. Returns an empty vec on
|
||||||
/// the API's JSON shape. Returns an empty vec on any error (missing
|
/// any error (missing review, network failure) so callers can degrade
|
||||||
/// review, network failure) so callers can degrade gracefully.
|
/// gracefully.
|
||||||
fn fetch_inline_comments(client: &Client, pr: u64, review_id: i64) -> Vec<Value> {
|
fn fetch_inline_comments(client: &Client, repo: &str, pr: u64, review_id: u64) -> Vec<Value> {
|
||||||
let Ok((owner, name)) = client.owner_repo() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Ok(idx) = index(pr) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
client
|
client
|
||||||
.api()
|
.get_json(&format!(
|
||||||
.repo_get_pull_review_comments(owner, name, idx, review_id)
|
"/repos/{repo}/pulls/{pr}/reviews/{review_id}/comments"
|
||||||
.send()
|
))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|comments| serde_json::to_value(comments).ok())
|
|
||||||
.and_then(|v| v.as_array().cloned())
|
.and_then(|v| v.as_array().cloned())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all reviews for a PR, dispatching to the appropriate output mode.
|
/// List all reviews for a PR, dispatching to the appropriate output mode.
|
||||||
fn list_reviews(client: &Client, number: u64) -> Result<()> {
|
fn list_reviews(client: &Client, number: u64) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let (_, reviews) = client
|
let v = client.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))?;
|
||||||
.api()
|
let reviews = v.as_array().cloned().unwrap_or_default();
|
||||||
.repo_list_pull_reviews(owner, name, index(number)?)
|
|
||||||
.send()?;
|
|
||||||
let reviews = serde_json::to_value(reviews)?;
|
|
||||||
let reviews = reviews.as_array().cloned().unwrap_or_default();
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
list_reviews_json(client, number, &reviews)
|
list_reviews_json(client, repo, number, &reviews)
|
||||||
} else {
|
} else {
|
||||||
list_reviews_text(client, number, &reviews);
|
list_reviews_text(client, repo, number, &reviews);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON output: one object per review, with an inline `comments` array.
|
/// JSON output: one object per review, with an inline `comments` array.
|
||||||
fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<()> {
|
fn list_reviews_json(client: &Client, repo: &str, number: u64, reviews: &[Value]) -> Result<()> {
|
||||||
let trimmed: Vec<Value> = reviews
|
let trimmed: Vec<Value> = reviews
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let id = r.get("id").and_then(Value::as_i64).unwrap_or(0);
|
let id = r.get("id").and_then(Value::as_u64).unwrap_or(0);
|
||||||
let inline: Vec<Value> = if id > 0 {
|
let inline: Vec<Value> = if id > 0 {
|
||||||
fetch_inline_comments(client, number, id)
|
fetch_inline_comments(client, repo, number, id)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -147,13 +130,13 @@ fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<
|
||||||
|
|
||||||
/// Human-readable output: Markdown-style heading per review, inline
|
/// Human-readable output: Markdown-style heading per review, inline
|
||||||
/// comments as `[path:line] body` (line omitted for PR-level comments).
|
/// comments as `[path:line] body` (line omitted for PR-level comments).
|
||||||
fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) {
|
fn list_reviews_text(client: &Client, repo: &str, number: u64, reviews: &[Value]) {
|
||||||
if reviews.is_empty() {
|
if reviews.is_empty() {
|
||||||
println!("(no reviews)");
|
println!("(no reviews)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for r in reviews {
|
for r in reviews {
|
||||||
let id = r.get("id").and_then(Value::as_i64).unwrap_or(0);
|
let id = r.get("id").and_then(Value::as_u64).unwrap_or(0);
|
||||||
let user = r
|
let user = r
|
||||||
.get("user")
|
.get("user")
|
||||||
.and_then(|u| u.get("login"))
|
.and_then(|u| u.get("login"))
|
||||||
|
|
@ -166,7 +149,7 @@ fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) {
|
||||||
println!("{body}");
|
println!("{body}");
|
||||||
}
|
}
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
for c in &fetch_inline_comments(client, number, id) {
|
for c in &fetch_inline_comments(client, repo, number, id) {
|
||||||
let path = c.get("path").and_then(Value::as_str).unwrap_or("?");
|
let path = c.get("path").and_then(Value::as_str).unwrap_or("?");
|
||||||
let cbody = c.get("body").and_then(Value::as_str).unwrap_or("").trim();
|
let cbody = c.get("body").and_then(Value::as_str).unwrap_or("").trim();
|
||||||
// PR-level comments have no line; omit `:line` when absent.
|
// PR-level comments have no line; omit `:line` when absent.
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,10 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{CommitStatusState, IssueGetCommentsQuery};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::{print_json, rfc3339};
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -39,29 +38,14 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
match (args.pr, args.sha) {
|
match (args.pr, args.sha) {
|
||||||
(Some(pr), _) => pr_status(client, repo, pr),
|
(Some(pr), _) => pr_status(client, repo, pr),
|
||||||
(None, Some(sha)) => sha_status(client, &sha),
|
(None, Some(sha)) => sha_status(client, repo, &sha),
|
||||||
(None, None) => bail!("pr-status: pass one of --pr <n> or --sha <sha>"),
|
(None, None) => bail!("pr-status: pass one of --pr <n> or --sha <sha>"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wire string for a combined/per-context CI status state, matching
|
|
||||||
/// what the raw API emitted (`""` when absent). Shared with `pr-merge`
|
|
||||||
/// for its "CI is not green" message.
|
|
||||||
pub(crate) fn status_state_str(state: Option<CommitStatusState>) -> &'static str {
|
|
||||||
match state {
|
|
||||||
Some(CommitStatusState::Pending) => "pending",
|
|
||||||
Some(CommitStatusState::Success) => "success",
|
|
||||||
Some(CommitStatusState::Error) => "error",
|
|
||||||
Some(CommitStatusState::Failure) => "failure",
|
|
||||||
Some(CommitStatusState::Warning) => "warning",
|
|
||||||
Some(CommitStatusState::Skipped) => "skipped",
|
|
||||||
None => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CI-only path for an explicit commit. Exit code mirrors the CI verdict.
|
/// CI-only path for an explicit commit. Exit code mirrors the CI verdict.
|
||||||
fn sha_status(client: &Client, sha: &str) -> Result<()> {
|
fn sha_status(client: &Client, repo: &str, sha: &str) -> Result<()> {
|
||||||
let (state, statuses) = fetch_combined(client, sha)?;
|
let (state, statuses) = fetch_combined(client, repo, sha)?;
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
print_json(&combined_json(sha, &state, &statuses))?;
|
print_json(&combined_json(sha, &state, &statuses))?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -76,36 +60,33 @@ fn sha_status(client: &Client, sha: &str) -> Result<()> {
|
||||||
|
|
||||||
/// Full PR health view. Exit code is a merge-readiness verdict.
|
/// Full PR health view. Exit code is a merge-readiness verdict.
|
||||||
fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
|
fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?;
|
||||||
let pull = client
|
let title = pull.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
.api()
|
let state = pull.get("state").and_then(Value::as_str).unwrap_or("?");
|
||||||
.repo_get_pull_request(owner, name, index(pr)?)
|
let merged = pull.get("merged").and_then(Value::as_bool).unwrap_or(false);
|
||||||
.send()?;
|
|
||||||
let title = pull.title.as_deref().unwrap_or("");
|
|
||||||
let state = pull.state.map_or("?", |s| match s {
|
|
||||||
forgejo_api::structs::StateType::Open => "open",
|
|
||||||
forgejo_api::structs::StateType::Closed => "closed",
|
|
||||||
});
|
|
||||||
let merged = pull.merged.unwrap_or(false);
|
|
||||||
// `mergeable` is `null` while the forge is still computing it.
|
// `mergeable` is `null` while the forge is still computing it.
|
||||||
let mergeable = pull.mergeable;
|
let mergeable = pull.get("mergeable").and_then(Value::as_bool);
|
||||||
let sha = pull
|
let sha = pull
|
||||||
.head
|
.get("head")
|
||||||
.as_ref()
|
.and_then(|h| h.get("sha"))
|
||||||
.and_then(|h| h.sha.clone())
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
.with_context(|| format!("pr-status: PR #{pr} has no head.sha"))?;
|
.with_context(|| format!("pr-status: PR #{pr} has no head.sha"))?;
|
||||||
|
|
||||||
let requested: Vec<String> = pull
|
let requested = pull
|
||||||
.requested_reviewers
|
.get("requested_reviewers")
|
||||||
.as_deref()
|
.and_then(Value::as_array)
|
||||||
.unwrap_or_default()
|
.map(|a| {
|
||||||
.iter()
|
a.iter()
|
||||||
.filter_map(|u| u.login.clone())
|
.filter_map(|u| u.get("login").and_then(Value::as_str))
|
||||||
.collect();
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let (ci_state, ci_statuses) = fetch_combined(client, &sha)?;
|
let (ci_state, ci_statuses) = fetch_combined(client, repo, &sha)?;
|
||||||
let reviews = super::latest_reviews(client, repo, pr)?;
|
let reviews = super::latest_reviews(client, repo, pr)?;
|
||||||
let last = last_comment(client, pr)?;
|
let last = last_comment(client, repo, pr)?;
|
||||||
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
print_json(&serde_json::json!({
|
print_json(&serde_json::json!({
|
||||||
|
|
@ -155,56 +136,37 @@ fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the combined commit status: `(overall_state, statuses[])`.
|
/// Fetch the combined commit status: `(overall_state, statuses[])`.
|
||||||
/// Statuses ride as their serialized (API-shape) JSON so the render
|
fn fetch_combined(client: &Client, repo: &str, sha: &str) -> Result<(String, Vec<Value>)> {
|
||||||
/// helpers stay pure `Value` walkers.
|
let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?;
|
||||||
fn fetch_combined(client: &Client, sha: &str) -> Result<(String, Vec<Value>)> {
|
let state = combined
|
||||||
let (owner, name) = client.owner_repo()?;
|
.get("state")
|
||||||
let (_, combined) = client
|
.and_then(Value::as_str)
|
||||||
.api()
|
.unwrap_or("")
|
||||||
.repo_get_combined_status_by_ref(owner, name, sha)
|
.to_owned();
|
||||||
.send()?;
|
|
||||||
let state = status_state_str(combined.state).to_owned();
|
|
||||||
let statuses = combined
|
let statuses = combined
|
||||||
.statuses
|
.get("statuses")
|
||||||
.unwrap_or_default()
|
.and_then(Value::as_array)
|
||||||
.iter()
|
.cloned()
|
||||||
.map(serde_json::to_value)
|
.unwrap_or_default();
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
Ok((state, statuses))
|
Ok((state, statuses))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The most recent issue comment on the PR, as `(login, created_at)`.
|
/// The most recent issue comment on the PR, as `(login, created_at)`.
|
||||||
/// Comments page oldest-first; we drain (bounded) and take the max
|
/// Comments page oldest-first; we drain (bounded) and take the max
|
||||||
/// timestamp so a long thread still reports the genuinely-latest one.
|
/// timestamp so a long thread still reports the genuinely-latest one.
|
||||||
fn last_comment(client: &Client, pr: u64) -> Result<Option<(String, String)>> {
|
fn last_comment(client: &Client, repo: &str, pr: u64) -> Result<Option<(String, String)>> {
|
||||||
const PAGE_SIZE: u32 = 50;
|
let comments = client.get_json_all(&format!("/repos/{repo}/issues/{pr}/comments"), 20)?;
|
||||||
const MAX_PAGES: u32 = 20;
|
let last = comments
|
||||||
let (owner, name) = client.owner_repo()?;
|
.iter()
|
||||||
let idx = index(pr)?;
|
.filter_map(|c| {
|
||||||
let mut last: Option<(String, String)> = None;
|
let login = c
|
||||||
for page in 1..=MAX_PAGES {
|
.get("user")
|
||||||
let (_, comments) = client
|
.and_then(|u| u.get("login"))
|
||||||
.api()
|
.and_then(Value::as_str)?;
|
||||||
.issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default())
|
let created = c.get("created_at").and_then(Value::as_str)?;
|
||||||
.page(page)
|
Some((login.to_owned(), created.to_owned()))
|
||||||
.page_size(PAGE_SIZE)
|
})
|
||||||
.send()?;
|
.max_by(|a, b| a.1.cmp(&b.1));
|
||||||
let short = comments.len() < PAGE_SIZE as usize;
|
|
||||||
for c in &comments {
|
|
||||||
let Some(login) = c.user.as_ref().and_then(|u| u.login.clone()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(created) = rfc3339(c.created_at) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if last.as_ref().is_none_or(|(_, t)| created > *t) {
|
|
||||||
last = Some((login, created));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(last)
|
Ok(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,19 +323,6 @@ mod tests {
|
||||||
assert_eq!(status_mark("weird"), "•");
|
assert_eq!(status_mark("weird"), "•");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_state_str_matches_wire_names() {
|
|
||||||
assert_eq!(
|
|
||||||
status_state_str(Some(CommitStatusState::Success)),
|
|
||||||
"success"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
status_state_str(Some(CommitStatusState::Failure)),
|
|
||||||
"failure"
|
|
||||||
);
|
|
||||||
assert_eq!(status_state_str(None), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn combined_json_shape() {
|
fn combined_json_shape() {
|
||||||
let v = combined_json("abc", "success", &[]);
|
let v = combined_json("abc", "success", &[]);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -20,21 +20,16 @@ pub struct Args {
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error when the `PATCH /repos/{repo}/issues/{number}` request
|
/// Returns an error when the `PATCH /repos/{repo}/issues/{number}` request
|
||||||
/// fails (network / non-success status) or when emitting the JSON summary
|
/// fails (network / non-success status from `patch_json`) or when emitting
|
||||||
/// via `print_json` fails.
|
/// the JSON summary via `print_json` fails.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let resp = client
|
let resp = client.patch_json(
|
||||||
.api()
|
&format!("/repos/{repo}/issues/{}", args.number),
|
||||||
.issue_edit_issue(
|
&json!({ "state": "open" }),
|
||||||
owner,
|
)?;
|
||||||
name,
|
|
||||||
index(args.number)?,
|
|
||||||
super::close::state_edit("open"),
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
print_json(&json!({
|
print_json(&json!({
|
||||||
"number": resp.number,
|
"number": resp.get("number"),
|
||||||
"state": resp.state,
|
"state": resp.get("state"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use forgejo_api::structs::{AddCollaboratorOption, AddCollaboratorOptionPermission};
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
|
|
||||||
|
|
@ -25,8 +25,7 @@ pub enum Permission {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Permission {
|
impl Permission {
|
||||||
/// The wire string Forgejo's API expects (used for the printed
|
/// The wire string Forgejo's API expects.
|
||||||
/// confirmation).
|
|
||||||
fn as_api(self) -> &'static str {
|
fn as_api(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Permission::Read => "read",
|
Permission::Read => "read",
|
||||||
|
|
@ -34,15 +33,6 @@ impl Permission {
|
||||||
Permission::Admin => "admin",
|
Permission::Admin => "admin",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed permission for the request body.
|
|
||||||
fn as_option(self) -> AddCollaboratorOptionPermission {
|
|
||||||
match self {
|
|
||||||
Permission::Read => AddCollaboratorOptionPermission::Read,
|
|
||||||
Permission::Write => AddCollaboratorOptionPermission::Write,
|
|
||||||
Permission::Admin => AddCollaboratorOptionPermission::Admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -63,19 +53,11 @@ pub struct Args {
|
||||||
/// writing the confirmation to stdout.
|
/// writing the confirmation to stdout.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let (owner, name) = client.owner_repo()?;
|
|
||||||
let perm = args.permission.as_api();
|
let perm = args.permission.as_api();
|
||||||
client
|
client.put_no_content(
|
||||||
.api()
|
&format!("/repos/{repo}/collaborators/{}", args.user),
|
||||||
.repo_add_collaborator(
|
&json!({ "permission": perm }),
|
||||||
owner,
|
)?;
|
||||||
name,
|
|
||||||
&args.user,
|
|
||||||
AddCollaboratorOption {
|
|
||||||
permission: Some(args.permission.as_option()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
println!("added {} to {repo} ({perm})", args.user);
|
println!("added {} to {repo} ({perm})", args.user);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::CreateRepoOption;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
@ -48,31 +48,29 @@ pub struct Args {
|
||||||
/// permission on the target namespace, token missing/invalid) and any
|
/// permission on the target namespace, token missing/invalid) and any
|
||||||
/// I/O error from writing the result to stdout.
|
/// I/O error from writing the result to stdout.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let payload = CreateRepoOption {
|
let mut payload = json!({
|
||||||
auto_init: Some(args.auto_init),
|
"name": args.name,
|
||||||
default_branch: args.default_branch,
|
"private": args.private,
|
||||||
description: args.description,
|
"auto_init": args.auto_init,
|
||||||
gitignores: None,
|
});
|
||||||
issue_labels: None,
|
if let Some(d) = args.description {
|
||||||
license: None,
|
payload["description"] = json!(d);
|
||||||
name: args.name,
|
}
|
||||||
object_format_name: None,
|
if let Some(b) = args.default_branch {
|
||||||
private: Some(args.private),
|
payload["default_branch"] = json!(b);
|
||||||
readme: None,
|
}
|
||||||
template: None,
|
|
||||||
trust_model: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let resp = match args.org.as_deref() {
|
let path = match args.org.as_deref() {
|
||||||
Some(org) => client.api().create_org_repo(org, payload).send()?,
|
Some(org) => format!("/orgs/{org}/repos"),
|
||||||
None => client.api().create_current_user_repo(payload).send()?,
|
None => "/user/repos".to_owned(),
|
||||||
};
|
};
|
||||||
|
let resp = client.post_json(&path, &payload)?;
|
||||||
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
return print_json(&serde_json::to_value(&resp)?);
|
return print_json(&resp);
|
||||||
}
|
}
|
||||||
// Default human path: print the web URL, like issue-create / pr-create.
|
// Default human path: print the web URL, like issue-create / pr-create.
|
||||||
if let Some(url) = resp.html_url {
|
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||||
println!("{url}");
|
println!("{url}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,11 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{IssueListLabelsQuery, Label};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
/// Page size on the label list endpoint.
|
|
||||||
const PAGE_SIZE: u32 = 50;
|
|
||||||
|
|
||||||
/// Runaway cap on label pagination — same ceiling the raw client used.
|
|
||||||
const MAX_PAGES: u32 = 10;
|
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// Substring pattern to filter label names (case-sensitive).
|
/// Substring pattern to filter label names (case-sensitive).
|
||||||
|
|
@ -23,46 +17,34 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
// Repos can carry more than one page of labels; paginate so the list
|
// Repos can carry more than one page of labels; paginate so the list
|
||||||
// is complete rather than capped at the first page.
|
// is complete rather than capped at the first page.
|
||||||
let mut labels: Vec<Label> = Vec::new();
|
let labels = client.get_json_all(&format!("/repos/{repo}/labels"), 10)?;
|
||||||
for page in 1..=MAX_PAGES {
|
let filtered: Vec<&Value> = labels
|
||||||
let (_, batch) = client
|
|
||||||
.api()
|
|
||||||
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
|
|
||||||
.page(page)
|
|
||||||
.page_size(PAGE_SIZE)
|
|
||||||
.send()?;
|
|
||||||
let short = batch.len() < PAGE_SIZE as usize;
|
|
||||||
labels.extend(batch);
|
|
||||||
if short {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let filtered: Vec<&Label> = labels
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| {
|
.filter(|l| {
|
||||||
let label_name = l.name.as_deref().unwrap_or_default();
|
let name = l.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||||
args.pattern
|
args.pattern.as_deref().is_none_or(|p| name.contains(p))
|
||||||
.as_deref()
|
|
||||||
.is_none_or(|p| label_name.contains(p))
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
// Full label objects (id, name, color, description) as a JSON array.
|
// Full label objects (id, name, color, description) as a JSON array.
|
||||||
print_json(&serde_json::to_value(&filtered)?)?;
|
print_json(&json!(filtered))?;
|
||||||
} else {
|
} else {
|
||||||
// One label per line: `name`, plus its description (tab-separated)
|
// One label per line: `name`, plus its description (tab-separated)
|
||||||
// when set, so triage can see what each label means at a glance.
|
// when set, so triage can see what each label means at a glance.
|
||||||
for l in filtered {
|
for l in filtered {
|
||||||
let label_name = l.name.as_deref().unwrap_or_default();
|
let name = l.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||||
let desc = l.description.as_deref().unwrap_or_default();
|
let desc = l
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
if desc.is_empty() {
|
if desc.is_empty() {
|
||||||
println!("{label_name}");
|
println!("{name}");
|
||||||
} else {
|
} else {
|
||||||
println!("{label_name}\t{desc}");
|
println!("{name}\t{desc}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, is_not_found};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -38,7 +38,7 @@ pub struct Args {
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Propagates transport / non-2xx errors from the Forgejo client calls
|
/// Propagates transport / non-2xx errors from the Forgejo client calls
|
||||||
/// and from `print_json`.
|
/// (`get_json` / `put_json` / `delete`) and from `print_json`.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
if args.list {
|
if args.list {
|
||||||
// List every repo the authed user watches, so an agent can audit
|
// List every repo the authed user watches, so an agent can audit
|
||||||
|
|
@ -49,56 +49,48 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
const PAGE: u32 = 50;
|
const PAGE: u32 = 50;
|
||||||
let mut watching: Vec<String> = Vec::new();
|
let mut watching: Vec<String> = Vec::new();
|
||||||
for page in 1..=MAX_PAGES {
|
for page in 1..=MAX_PAGES {
|
||||||
let (_, repos) = client
|
let v = client.get_json(&format!("/user/subscriptions?page={page}&limit={PAGE}"))?;
|
||||||
.api()
|
let arr = v.as_array().cloned().unwrap_or_default();
|
||||||
.user_current_list_subscriptions()
|
let n = arr.len();
|
||||||
.page(page)
|
watching.extend(
|
||||||
.page_size(PAGE)
|
arr.iter()
|
||||||
.send()?;
|
.filter_map(|r| r["full_name"].as_str().map(str::to_owned)),
|
||||||
let n = repos.len();
|
);
|
||||||
watching.extend(repos.into_iter().filter_map(|r| r.full_name));
|
|
||||||
if n < PAGE as usize {
|
if n < PAGE as usize {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return print_json(&json!({ "watching": watching }));
|
return print_json(&json!({ "watching": watching }));
|
||||||
}
|
}
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
if args.unwatch {
|
if args.unwatch {
|
||||||
client
|
client.delete(&format!("/repos/{repo}/subscription"), None)?;
|
||||||
.api()
|
|
||||||
.user_current_delete_subscription(owner, name)
|
|
||||||
.send()?;
|
|
||||||
println!("unsubscribed");
|
println!("unsubscribed");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if args.watch || args.ignore {
|
if args.watch || args.ignore {
|
||||||
// Forgejo's PUT subscription endpoint takes no body — it always
|
let (subscribed, ignored) = if args.ignore {
|
||||||
// subscribes (watch). The old raw client sent a
|
(false, true)
|
||||||
// `{subscribed, ignored}` body which the server ignored, so
|
} else {
|
||||||
// `--ignore` has always behaved like `--watch` server-side; the
|
(true, false)
|
||||||
// typed call just makes that explicit.
|
};
|
||||||
let resp = client
|
// Forgejo requires PUT (not POST) for this endpoint.
|
||||||
.api()
|
let resp = client.put_json(
|
||||||
.user_current_put_subscription(owner, name)
|
&format!("/repos/{repo}/subscription"),
|
||||||
.send()?;
|
&json!({ "subscribed": subscribed, "ignored": ignored }),
|
||||||
|
)?;
|
||||||
return print_json(&json!({
|
return print_json(&json!({
|
||||||
"subscribed": resp.subscribed,
|
"subscribed": resp.get("subscribed"),
|
||||||
"ignored": resp.ignored,
|
"ignored": resp.get("ignored"),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Forgejo returns 404 when the current user is not watching the repo
|
// Forgejo returns 404 when the current user is not watching the repo
|
||||||
// (rather than a response with subscribed=false).
|
// (rather than a response with subscribed=false).
|
||||||
match client
|
match client.get_json_optional(&format!("/repos/{repo}/subscription"))? {
|
||||||
.api()
|
Some(resp) => print_json(&json!({
|
||||||
.user_current_check_subscription(owner, name)
|
"subscribed": resp.get("subscribed"),
|
||||||
.send()
|
"ignored": resp.get("ignored"),
|
||||||
{
|
|
||||||
Ok(resp) => print_json(&json!({
|
|
||||||
"subscribed": resp.subscribed,
|
|
||||||
"ignored": resp.ignored,
|
|
||||||
})),
|
})),
|
||||||
Err(e) if is_not_found(&e) => print_json(&json!({ "subscribed": false, "ignored": false })),
|
None => print_json(&json!({ "subscribed": false, "ignored": false })),
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::IssueGetCommentsAndTimelineQuery;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -34,20 +33,11 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
let (_, events) = client
|
let v = client.get_json(&format!(
|
||||||
.api()
|
"/repos/{repo}/issues/{}/timeline?limit={}",
|
||||||
.issue_get_comments_and_timeline(
|
args.number, args.limit
|
||||||
owner,
|
))?;
|
||||||
name,
|
|
||||||
index(args.number)?,
|
|
||||||
IssueGetCommentsAndTimelineQuery::default(),
|
|
||||||
)
|
|
||||||
.page_size(u32::try_from(args.limit).unwrap_or(u32::MAX))
|
|
||||||
.send()?;
|
|
||||||
// Serialize back to the API's JSON shape so the per-type render
|
|
||||||
// arms (and their tests) keep working on plain `Value`s.
|
|
||||||
let v = serde_json::to_value(events)?;
|
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
return print_json(&v);
|
return print_json(&v);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::RepoGetSingleCommitQuery;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
|
|
||||||
|
|
@ -14,35 +14,25 @@ pub struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let (owner, name) = client.owner_repo()?;
|
let repo = client.repo();
|
||||||
// Try branch first; the bash helper silently treats failure as
|
// Try branch first; the bash helper silently treats failure as
|
||||||
// "not a branch, use the reference as a commit sha directly".
|
// "not a branch, use the reference as a commit sha directly".
|
||||||
let commit_sha = match client
|
let commit_sha = match client.get_json(&format!("/repos/{repo}/branches/{}", args.reference)) {
|
||||||
.api()
|
Ok(v) => v
|
||||||
.repo_get_branch(owner, name, &args.reference)
|
.get("commit")
|
||||||
.send()
|
.and_then(|c| c.get("id"))
|
||||||
{
|
.and_then(Value::as_str)
|
||||||
Ok(branch) => branch
|
.unwrap_or(&args.reference)
|
||||||
.commit
|
.to_owned(),
|
||||||
.and_then(|c| c.id)
|
|
||||||
.unwrap_or_else(|| args.reference.clone()),
|
|
||||||
Err(_) => args.reference.clone(),
|
Err(_) => args.reference.clone(),
|
||||||
};
|
};
|
||||||
let commit = client
|
let commit = client.get_json(&format!("/repos/{repo}/git/commits/{commit_sha}"))?;
|
||||||
.api()
|
|
||||||
.repo_get_single_commit(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
&commit_sha,
|
|
||||||
RepoGetSingleCommitQuery::default(),
|
|
||||||
)
|
|
||||||
.send()?;
|
|
||||||
let sha = commit
|
let sha = commit
|
||||||
.commit
|
.get("tree")
|
||||||
.and_then(|c| c.tree)
|
.and_then(|t| t.get("sha"))
|
||||||
.and_then(|t| t.sha)
|
.and_then(Value::as_str)
|
||||||
.or(commit.sha)
|
.or_else(|| commit.get("sha").and_then(Value::as_str))
|
||||||
.unwrap_or_default();
|
.unwrap_or("");
|
||||||
println!("{sha}");
|
println!("{sha}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
use forgejo_api::structs::{IssueGetCommentsQuery, StateType};
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
use crate::client::Client;
|
||||||
use crate::notify;
|
use crate::notify;
|
||||||
|
|
||||||
#[derive(ClapArgs)]
|
#[derive(ClapArgs)]
|
||||||
|
|
@ -19,24 +19,16 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
// 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()?;
|
let issue = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||||
let issue = client
|
let title = issue.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
.api()
|
let body = issue.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
.issue_get_issue(owner, name, index(args.number)?)
|
let state = issue.get("state").and_then(Value::as_str).unwrap_or("?");
|
||||||
.send()?;
|
|
||||||
let title = issue.title.as_deref().unwrap_or("");
|
|
||||||
let body = issue.body.as_deref().unwrap_or("");
|
|
||||||
let state = match issue.state {
|
|
||||||
Some(StateType::Open) => "open",
|
|
||||||
Some(StateType::Closed) => "closed",
|
|
||||||
None => "?",
|
|
||||||
};
|
|
||||||
let user = issue
|
let user = issue
|
||||||
.user
|
.get("user")
|
||||||
.as_ref()
|
.and_then(|u| u.get("login"))
|
||||||
.and_then(|u| u.login.as_deref())
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
let kind = if issue.pull_request.is_some() {
|
let kind = if issue.get("pull_request").is_some_and(|p| !p.is_null()) {
|
||||||
"PR"
|
"PR"
|
||||||
} else {
|
} else {
|
||||||
"issue"
|
"issue"
|
||||||
|
|
@ -47,28 +39,23 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
println!();
|
println!();
|
||||||
println!("{body}");
|
println!("{body}");
|
||||||
}
|
}
|
||||||
let (_, comments) = client
|
let comments = client.get_json(&format!(
|
||||||
.api()
|
"/repos/{repo}/issues/{}/comments?limit=50",
|
||||||
.issue_get_comments(
|
args.number
|
||||||
owner,
|
))?;
|
||||||
name,
|
let arr = comments.as_array().cloned().unwrap_or_default();
|
||||||
index(args.number)?,
|
if !arr.is_empty() {
|
||||||
IssueGetCommentsQuery::default(),
|
|
||||||
)
|
|
||||||
.page_size(50)
|
|
||||||
.send()?;
|
|
||||||
if !comments.is_empty() {
|
|
||||||
println!();
|
println!();
|
||||||
println!("---");
|
println!("---");
|
||||||
println!("## Comments ({})", comments.len());
|
println!("## Comments ({})", arr.len());
|
||||||
println!();
|
println!();
|
||||||
for c in &comments {
|
for c in &arr {
|
||||||
let cu = c
|
let cu = c
|
||||||
.user
|
.get("user")
|
||||||
.as_ref()
|
.and_then(|u| u.get("login"))
|
||||||
.and_then(|u| u.login.as_deref())
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
let cb = c.body.as_deref().unwrap_or("");
|
let cb = c.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
println!("**{cu}**: {cb}");
|
println!("**{cu}**: {cb}");
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Resolve the on-disk path to hyperhive's static assets (branding +
|
//! Resolve the on-disk path to hyperhive's static assets (branding +
|
||||||
//! claude prompts). Single source of truth for both the host daemon
|
//! claude prompts). Single source of truth for both the host daemon
|
||||||
//! (`hive-c0re`) and the in-container harness binaries so they agree
|
//! (`hive-c0re`) and the in-container harness (the `hive` binary) so
|
||||||
//! on the lookup contract.
|
//! they agree on the lookup contract.
|
||||||
//!
|
//!
|
||||||
//! At runtime, the path is read from `$HIVE_ASSETS_DIR`. In nix
|
//! At runtime, the path is read from `$HIVE_ASSETS_DIR`. In nix
|
||||||
//! builds that env var is set by the `hive-c0re` / `harness-base` modules
|
//! builds that env var is set by the `hive-c0re` / `harness-base` modules
|
||||||
|
|
|
||||||
|
|
@ -898,13 +898,12 @@ in
|
||||||
streamable-http daemon on this loopback port instead of the
|
streamable-http daemon on this loopback port instead of the
|
||||||
default per-turn stdio child.
|
default per-turn stdio child.
|
||||||
|
|
||||||
When null (the default) claude spawns a fresh `hive-agent-mcp`
|
When null (the default) claude spawns a fresh `hive … mcp` stdio
|
||||||
stdio subprocess every turn — the shape that carries the per-turn
|
subprocess every turn — the shape that carries the per-turn MCP
|
||||||
MCP re-registration race (a resumed turn can emit its first tool
|
re-registration race (a resumed turn can emit its first tool call
|
||||||
call before that turn's async `initialize`/`tools-list` completes,
|
before that turn's async `initialize`/`tools-list` completes,
|
||||||
stranding the agent with `No such tool`). When set, a long-lived
|
stranding the agent with `No such tool`). When set, a long-lived
|
||||||
`hive-mcp-http` systemd unit runs
|
`hive-mcp-http` systemd unit runs `hive mcp --http 127.0.0.1:<port>`
|
||||||
`hive-agent-mcp --http 127.0.0.1:<port>`
|
|
||||||
and `render_claude_config` points claude at the stable
|
and `render_claude_config` points claude at the stable
|
||||||
`http://127.0.0.1:<port>/mcp` URL, which survives the per-turn
|
`http://127.0.0.1:<port>/mcp` URL, which survives the per-turn
|
||||||
claude re-spawn (and a host-side hive-c0re restart — each tool call
|
claude re-spawn (and a host-side hive-c0re restart — each tool call
|
||||||
|
|
@ -1847,7 +1846,7 @@ in
|
||||||
# `render_claude_config`). Long-lived so claude reconnects to the
|
# `render_claude_config`). Long-lived so claude reconnects to the
|
||||||
# stable URL each turn instead of respawning + re-registering a stdio
|
# stable URL each turn instead of respawning + re-registering a stdio
|
||||||
# subprocess (the per-turn MCP registration race). It dials the
|
# subprocess (the per-turn MCP registration race). It dials the
|
||||||
# control socket (`/run/hive/mcp.sock`, the harness binaries' default)
|
# control socket (`/run/hive/mcp.sock`, the `hive` binary default)
|
||||||
# fresh on every tool call, so a host-side hive-c0re restart is
|
# fresh on every tool call, so a host-side hive-c0re restart is
|
||||||
# transparent. `before = hive-ag3nt` so the URL is already listening
|
# transparent. `before = hive-ag3nt` so the URL is already listening
|
||||||
# by the time the harness renders the first turn's config; the
|
# by the time the harness renders the first turn's config; the
|
||||||
|
|
@ -1859,7 +1858,7 @@ in
|
||||||
before = [ "hive-ag3nt.service" ];
|
before = [ "hive-ag3nt.service" ];
|
||||||
environment.RUST_LOG = "info";
|
environment.RUST_LOG = "info";
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
|
ExecStart = "${pkgs.hyperhive}/bin/hive mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
|
||||||
SyslogIdentifier = "hive-mcp-http";
|
SyslogIdentifier = "hive-mcp-http";
|
||||||
# `always` (not `on-failure`): this endpoint is load-bearing when
|
# `always` (not `on-failure`): this endpoint is load-bearing when
|
||||||
# `httpPort` is set — a down window is total hyperhive-MCP loss with
|
# `httpPort` is set — a down window is total hyperhive-MCP loss with
|
||||||
|
|
@ -1937,7 +1936,7 @@ in
|
||||||
# appends /bin to every entry.
|
# appends /bin to every entry.
|
||||||
systemd.services.hive-ag3nt =
|
systemd.services.hive-ag3nt =
|
||||||
let
|
let
|
||||||
binary = "hive-agent";
|
binary = "hive";
|
||||||
# OTEL is shipped declaratively via the managed claude settings
|
# OTEL is shipped declaratively via the managed claude settings
|
||||||
# json (`environment.etc."claude-code/managed-settings.json"`,
|
# json (`environment.etc."claude-code/managed-settings.json"`,
|
||||||
# `otelSettingsEnv` in the top-level let) — claude reads it for
|
# `otelSettingsEnv` in the top-level let) — claude reads it for
|
||||||
|
|
@ -1983,7 +1982,7 @@ in
|
||||||
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||||
};
|
};
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/${binary}";
|
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||||
# Pin the journal identity to the binary name (otherwise systemd
|
# Pin the journal identity to the binary name (otherwise systemd
|
||||||
# derives SyslogIdentifier from the ExecStart basename).
|
# derives SyslogIdentifier from the ExecStart basename).
|
||||||
SyslogIdentifier = binary;
|
SyslogIdentifier = binary;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue