Compare commits

...
Author SHA1 Message Date
Kevin CodexandClaude Opus 4.7 14a1cdd656 chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint (#12)
* chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint

- Bump workspace version 0.3.8 → 0.3.9
- Fix 22 clippy warnings across 17 files:
  - Replace .last() with .next_back() on DoubleEndedIterators (8 instances)
  - Remove useless .into() conversions on anyhow::Error (3)
  - Remove needless borrows in gl init (3)
  - Replace map_or with is_some_and/is_none_or (3)
  - Collapse consecutive str::replace into char array pattern (2)
  - Change &PathBuf to &Path in function signatures (2)
  - Remove dead code: unused init_bare_repo function
  - Collapse if-let into outer match arm
  - Replace vec![...] with array literal
  - Introduce RecordAnchorInput struct (10 params → 1 struct)
  - Fix always-true boolean condition in gossip task guard
- Promote clippy from advisory to strict (-D warnings) in CI

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 10:28:14 +08:00
rapybus be099386e5 Update bootstrap-peers.json (#5) 2026-05-27 09:38:12 +08:00
b09714af0b fix(doctor): detect git-remote-gitlawb.exe on Windows (#10)
which_in_path() now also checks for `{name}.exe` on Windows targets,
so `gl doctor` correctly finds git-remote-gitlawb on that platform.

Co-authored-by: kelvinzer0 <[email protected]>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <[email protected]>
2026-05-27 09:37:58 +08:00
Rimantas (Rimas) MoceviciusandRimas Mocevicius d09c23da86 feat: macOS menu bar app for managing gitlawb-node Docker Compose stack (#8)
- Menu bar icon with colored status dot (green/yellow/red)
- Start/Stop node via Docker Compose
- Settings UI: ports, public URL, postgres password, auto-sync, operator config
- View Logs window with live docker compose logs
- Auto-start on launch and login (SMAppService)
- Docker detection with install prompt
- Repo root detection via compile-time path for local builds
- Port variables wired through docker-compose.yml for custom port support
- GITLAWB_AUTO_SYNC passthrough for peer replication toggle
- Build script: scripts/build-macos-app.sh (codesign + DMG)

Co-authored-by: Rimas Mocevicius <[email protected]>
2026-05-27 09:37:36 +08:00
Esky33 f3df4a4dd4 Resolve bare DID aliases for trust and cert lookups (#9)
* Resolve bare DID aliases for trust and cert lookups

Public profile and CLI surfaces accept bare z6Mk keys, but trust and certificate data are stored against full did:key identities. Normalize agent profile lookups and make cert commands resolve owner-less repos against the caller DID so valid pushes are not hidden behind node-owned paths.

Constraint: Public URLs and CLI examples commonly use short z6Mk identifiers

Constraint: Ref certificates are issued after push and stored under the owner repo id

Rejected: Register duplicate bare-key identities | would split trust and certificate history across aliases

Rejected: Require full certificate UUIDs in show output | list already displays short IDs as the human-facing handle

Confidence: high

Scope-risk: narrow

Directive: Keep bare key inputs as aliases for did:key, not separate identities

Tested: cargo check -p gl

Tested: cargo run -q -p gl -- cert list agentbot-opensource

Tested: cargo run -q -p gl -- cert show agentbot-opensource 3686b1fe

Tested: cargo check -p gitlawb-node

Tested: cargo test -p gitlawb-node normalize_agent_did

* Preserve agent rank on short profile URLs

The public profile route can use compact handles like z6MkpUq1, while trust history is stored under the full did:key identity. Resolve exact aliases first, then fall back to matching registered agent key prefixes so older high-level identities are not displayed as fresh newcomers.

Constraint: Public GitLawb profile URLs expose short z6Mk handles.

Constraint: Trust and push history remain keyed by canonical did:key identities.

Rejected: Mint duplicate short-handle agent rows | would split rank and push history across aliases.

Confidence: high

Scope-risk: narrow

Directive: Keep short profile handles as aliases for canonical did:key identities, never as separate trust principals.

Tested: cargo fmt --check -p gitlawb-node

Tested: cargo test -p gitlawb-node agent

Tested: cargo check -p gitlawb-node

Tested: cargo check -p gl
2026-05-27 09:36:37 +08:00
Kevin CodexandOpenClaude 034cb3c80c feat: replica registration protocol (Phase 2 v1) (#4)
Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-authored-by: OpenClaude <[email protected]>
2026-05-27 09:36:12 +08:00
d2a440e521 Feat/backport perf fixes (#11)
* feat: replica registration protocol (Phase 2 v1)

Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-Authored-By: OpenClaude <[email protected]>

* perf: backport query optimizations + add Fly test deployment

- Add expression indexes (owner_short+name, updated_at DESC) for fast repo listing
- Add list_all_repos_with_stars() batch query eliminating N+1 star count calls
- Add list_all_repos_paged() with DISTINCT ON dedup + pagination + X-Total-Count header
- Add prune_self_peers() to remove stale self-referencing peer rows at startup
- Add self-loop prevention in HTTP peer notifications during git-receive-pack
- Add SwarmStatus observability to p2p_info endpoint (connected/mesh/all peers)
- Add fly.toml for gitlawb-node-test deployment in iad region

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 09:35:46 +08:00
Kevin CodexandOpenClaude f312955c35 feat: replica registration protocol (Phase 2 v1) (#3)
Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-authored-by: OpenClaude <[email protected]>
2026-05-13 20:00:40 +08:00
Kevin CodexandOpenClaude ec4ef81770 feat: embed bootstrap peers seed list for automatic network discovery (#1)
* feat: embed bootstrap peers seed list for automatic network discovery

A fresh \`docker compose up\` now joins the Gitlawb network with zero
manual peer configuration. The node parses an embedded \`bootstrap-peers.json\`
on startup and merges the entries into both the HTTP gossip task and the
libp2p Kademlia bootstrap.

- Add \`bootstrap-peers.json\` at repo root (versioned schema, PR-friendly)
- New \`bootstrap\` module in the node crate (parse + merge_seeds)
- Wire into \`main\` after \`Config::parse\`
- Operators can opt out via \`GITLAWB_BOOTSTRAP_DISABLE_SEEDS\` for
  isolated dev networks

Also in this commit:
- \`cargo fmt --all\` over the entire workspace (no logic changes)
- Downgrade CI clippy step to advisory (\`continue-on-error: true\`) until
  the existing lint backlog is cleared. fmt + tests stay strict.

Co-Authored-By: OpenClaude <[email protected]>

* test: cover bootstrap merge logic with unit tests

Refactor bootstrap.rs into pure functions (parse_seed_list, merge_into_vecs)
so the parse + merge logic can be tested without constructing a Config or
mutating process-global env vars.

Adds 11 tests covering:

- valid v1 list parses
- unknown version is rejected
- malformed JSON is rejected
- empty / missing peers array
- merge appends new http + p2p entries
- merge dedupes against existing entries
- invalid p2p_multiaddr is skipped (http still added)
- empty strings are skipped
- null optional fields are tolerated
- the canonical bootstrap-peers.json shipped in the repo always parses
  (regression guard against future schema changes)

Co-Authored-By: OpenClaude <[email protected]>

* fix(security): reject path traversal in repo_store local_path

Closes CodeQL alert #4 (Uncontrolled data used in path expression).

Both `owner_did` and `repo_name` come from URL parameters and were used
unsanitized to build a filesystem path, so a request like
`/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos
directory.

Fix:

- New `validate_owner_did` and `validate_repo_name` enforce a strict
  allowlist before path construction (alphanumeric + `: . _ -` for DIDs;
  alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading
  `.` or `-`, slashes, backslashes, null bytes, overlong inputs).
- `RepoStore::local_path` now returns `Result<…>` and the 5 callers
  propagate the error.
- Defence in depth: even after the allowlist passes, the joined path is
  checked to still be rooted at `repos_dir`.
- 17 unit tests cover normal DIDs/repo names plus every malicious shape
  (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with
  dots).

Co-Authored-By: OpenClaude <[email protected]>

* fix(security): add Path::components barrier for CodeQL rust/path-injection

CodeQL's rust/path-injection query did not recognise the existing
allowlist validator as a sanitiser, so it kept flagging local_path even
though every malicious input was already rejected.

Adds an explicit Path::components() walk after path construction that
rejects any ParentDir or CurDir segment. This is the barrier pattern
CodeQL recognises — and is also genuine defence-in-depth in case a
future change ever weakens the allowlist.

Allowlist + starts_with + components walk = three independent layers
between attacker-controlled HTTP input and filesystem access. All 17
existing tests still pass.

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-05-13 19:11:54 +08:00
100 changed files with 6506 additions and 1647 deletions

No files matched your search

+2
View File
@@ -1,6 +1,7 @@
/target
**/*.rs.bk
Cargo.lock.bak
dist/
# Local node data
/data
@@ -21,3 +22,4 @@ keys/
# Logs
*.log
.openclaude-profile.json
Generated
+4 -4
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.3.8"
version = "0.3.9"
dependencies = [
"anyhow",
"gitlawb-core",
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.3.8"
version = "0.3.9"
dependencies = [
"anyhow",
"base64",
@@ -3385,7 +3385,7 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.3.8"
version = "0.3.9"
dependencies = [
"alloy",
"anyhow",
@@ -3428,7 +3428,7 @@ dependencies = [
[[package]]
name = "gl"
version = "0.3.8"
version = "0.3.9"
dependencies = [
"alloy",
"anyhow",
+1 -1
View File
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "0.3.8"
version = "0.3.9"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["gitlawb contributors"]
+1
View File
@@ -28,6 +28,7 @@ RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crate
# cargo can keep the dummy `fn main() {}` binaries from the cache layer above
# and the runtime container exits immediately with code 0.
COPY crates/ crates/
COPY bootstrap-peers.json ./
RUN find crates -name "*.rs" -exec touch {} + && \
rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
+40
View File
@@ -108,6 +108,46 @@ crates/
---
## macOS Menu Bar App
A native Swift/AppKit menu bar app that manages the Docker Compose stack (node + Postgres) without touching the terminal.
**Requirements:** macOS 26+, Xcode Command Line Tools (`xcode-select --install`), and a Docker runtime (Docker Desktop, OrbStack, or Colima).
### Build
```bash
./scripts/build-macos-app.sh
```
The resulting `Gitlawb Node.app` and `.dmg` are placed in `dist/`.
To codesign for distribution:
```bash
./scripts/build-macos-app.sh --sign "Developer ID Application: ..."
```
### Features
- Start/Stop the node from the menu bar
- Status indicator (green = running, yellow = starting, red = stopped)
- Settings GUI (ports, Postgres password, operator config)
- Auto-start on login
- Detects Docker Desktop, OrbStack, and Colima automatically
### Running an unsigned build
If you built the app locally without a Developer ID, macOS Gatekeeper will block it. To allow it:
```bash
xattr -cr "dist/Gitlawb Node.app"
```
Then open the app normally. Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway** after the first blocked launch attempt.
---
## Contributing
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security issues: see [`SECURITY.md`](SECURITY.md).
+23
View File
@@ -0,0 +1,23 @@
{
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-05-16",
"peers": [
{
"name": "gitlawb",
"operator": "Gitlawb (Kevin)",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-04-29"
},
{
"name": "rapybus-gitlawb",
"operator": "RapyBus S.A.C.",
"did": "did:key:z6MktERLyhFpjUyN7dnkf7CYY5RXKJ6BTZiKohoi37YVBS7R",
"http_url": "https://rapybus-gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-05-16"
}
]
}
+24 -24
View File
@@ -16,18 +16,16 @@
//! connect git-upload-pack → GET /info/refs | POST /git-upload-pack
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack (+ auth header)
use std::io::{self, BufRead, Read, Write};
use anyhow::{bail, Context, Result};
use gitlawb_core::identity::Keypair;
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use std::io::{self, BufRead, Read, Write};
fn main() -> Result<()> {
// All logging goes to stderr so it doesn't corrupt the git protocol on stdout
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
std::env::var("GITLAWB_LOG").unwrap_or_else(|_| "warn".to_string()),
)
.with_env_filter(std::env::var("GITLAWB_LOG").unwrap_or_else(|_| "warn".to_string()))
.init();
let args: Vec<String> = std::env::args().collect();
@@ -42,8 +40,8 @@ fn main() -> Result<()> {
let (_, short_owner, repo_name) = parse_gitlawb_url(url)?;
// v0.1: default to localhost. Override with GITLAWB_NODE env var.
let node_base = std::env::var("GITLAWB_NODE")
.unwrap_or_else(|_| "http://127.0.0.1:7545".to_string());
let node_base =
std::env::var("GITLAWB_NODE").unwrap_or_else(|_| "http://127.0.0.1:7545".to_string());
let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name);
tracing::debug!("repo_base: {repo_base}");
@@ -62,7 +60,9 @@ fn run_helper(repo_base: &str, keypair: Option<&Keypair>) -> Result<()> {
loop {
let mut line = String::new();
let n = stdin_buf.read_line(&mut line).context("reading command from git")?;
let n = stdin_buf
.read_line(&mut line)
.context("reading command from git")?;
if n == 0 {
break; // EOF
}
@@ -149,7 +149,10 @@ fn handle_connect(
// But git's `connect` protocol expects raw git-upload-pack output (no HTTP wrapper).
// Strip the service-line pkt-line + flush before forwarding.
let advertisement = strip_service_announcement(&refs_bytes);
tracing::debug!("ref advertisement: {} bytes (stripped)", advertisement.len());
tracing::debug!(
"ref advertisement: {} bytes (stripped)",
advertisement.len()
);
let mut stdout = io::stdout();
stdout.write_all(advertisement)?;
@@ -172,7 +175,9 @@ fn handle_connect(
read_upload_pack_request(stdin).context("reading upload-pack request")?
} else {
let mut buf = Vec::new();
stdin.read_to_end(&mut buf).context("reading receive-pack request")?;
stdin
.read_to_end(&mut buf)
.context("reading receive-pack request")?;
buf
};
@@ -192,10 +197,7 @@ fn handle_connect(
let mut req = client
.post(&post_url)
.header(
"Content-Type",
format!("application/x-{}-request", service),
)
.header("Content-Type", format!("application/x-{}-request", service))
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
.body(request_body.clone());
@@ -215,9 +217,7 @@ fn handle_connect(
}
}
let pack_resp = req
.send()
.with_context(|| format!("POST {post_url}"))?;
let pack_resp = req.send().with_context(|| format!("POST {post_url}"))?;
if !pack_resp.status().is_success() {
bail!("POST /{} returned {}", service, pack_resp.status());
@@ -296,7 +296,9 @@ fn read_upload_pack_request(stdin: &mut io::BufReader<io::Stdin>) -> Result<Vec<
let data_len = pkt_len - 4;
let mut data = vec![0u8; data_len];
stdin.read_exact(&mut data).context("reading pkt-line data")?;
stdin
.read_exact(&mut data)
.context("reading pkt-line data")?;
buf.extend_from_slice(&data);
// "done\n" signals the end of the want/have negotiation
@@ -380,8 +382,8 @@ fn load_keypair() -> Option<Keypair> {
}
fn resolve_key_path() -> std::path::PathBuf {
let path_str = std::env::var("GITLAWB_KEY")
.unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
let path_str =
std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
if let Some(stripped) = path_str.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
@@ -399,8 +401,7 @@ mod tests {
#[test]
fn parse_standard_url() {
let (did, owner, repo) =
parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo").unwrap();
let (did, owner, repo) = parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo").unwrap();
assert_eq!(did, "did:key:z6MkFoo123");
assert_eq!(owner, "z6MkFoo123");
assert_eq!(repo, "my-repo");
@@ -408,8 +409,7 @@ mod tests {
#[test]
fn parse_url_strips_dot_git() {
let (_, _, repo) =
parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo.git").unwrap();
let (_, _, repo) = parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo.git").unwrap();
assert_eq!(repo, "my-repo");
}
+16 -14
View File
@@ -10,9 +10,9 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::{Keypair, verify};
use crate::identity::{verify, Keypair};
use crate::{Error, Result};
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
@@ -110,7 +110,7 @@ impl RefUpdateCert {
///
/// Returns the list of DIDs whose signatures are valid.
pub fn verify_all(&self) -> Result<Vec<Did>> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let signing_bytes = self.body.to_signing_bytes()?;
let mut valid = Vec::new();
@@ -118,10 +118,12 @@ impl RefUpdateCert {
// Resolve the verifying key from the DID
let vk = cert_sig.signer.to_verifying_key()?;
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&cert_sig.sig)
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&cert_sig.sig)
.map_err(|e| Error::RefCert(format!("invalid base64 sig: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
.map_err(|_| Error::RefCert("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)?;
@@ -133,11 +135,7 @@ impl RefUpdateCert {
/// Check if this certificate satisfies a threshold of valid signatures
/// from the provided set of authorized maintainer DIDs.
pub fn satisfies_threshold(
&self,
maintainers: &[Did],
threshold: usize,
) -> Result<bool> {
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
let valid = self.verify_all()?;
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
Ok(count >= threshold)
@@ -187,7 +185,8 @@ mod tests {
dummy_hash('a'),
1,
&kp,
).unwrap();
)
.unwrap();
cert.validate_structure().unwrap();
let valid = cert.verify_all().unwrap();
@@ -208,7 +207,8 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
).unwrap();
)
.unwrap();
cert.countersign(&kp2).unwrap();
let valid = cert.verify_all().unwrap();
@@ -228,7 +228,8 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
).unwrap();
)
.unwrap();
cert.countersign(&kp2).unwrap();
let maintainers = vec![kp1.did(), kp2.did()];
@@ -246,7 +247,8 @@ mod tests {
dummy_hash('b'),
42,
&kp,
).unwrap();
)
.unwrap();
let json = serde_json::to_string_pretty(&cert).unwrap();
assert!(json.contains("gitlawb/ref-update/v1"));
+8 -5
View File
@@ -11,7 +11,7 @@
use cid::CidGeneric;
use multihash_codetable::{Code, MultihashDigest};
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
use std::fmt;
use crate::{Error, Result};
@@ -87,9 +87,9 @@ pub fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
/// Parse a 64-character hex SHA-256 string into raw bytes.
pub fn sha256_hex_to_bytes(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode(hex_str)
.map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes.try_into()
let bytes = hex::decode(hex_str).map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes
.try_into()
.map_err(|_| Error::InvalidCid("sha256 hash must be 32 bytes (64 hex chars)".to_string()))
}
@@ -110,7 +110,10 @@ mod tests {
// CIDv1 base32 strings start with 'b'
let data = b"blob 13\0hello gitlawb";
let c = Cid::from_git_object_bytes(data);
assert!(c.to_string().starts_with('b'), "CIDv1 should be base32 (starts with 'b')");
assert!(
c.to_string().starts_with('b'),
"CIDv1 should be base32 (starts with 'b')"
);
}
#[test]
+11 -7
View File
@@ -75,12 +75,13 @@ impl Did {
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
if !self.is_did_key() {
return Err(Error::InvalidDid(format!(
"expected did:key, got did:{}", self.method()
"expected did:key, got did:{}",
self.method()
)));
}
let (_, bytes) = multibase::decode(self.method_id())
.map_err(|e| Error::InvalidDid(e.to_string()))?;
let (_, bytes) =
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
if !bytes.starts_with(ED25519_MULTICODEC) {
return Err(Error::InvalidDid(
@@ -92,8 +93,7 @@ impl Did {
.try_into()
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::InvalidDid(e.to_string()))
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))
}
/// Return the full DID string as a `&str`.
@@ -105,7 +105,9 @@ impl Did {
pub fn validate(&self) -> Result<()> {
match self.method() {
"key" | "web" | "gitlawb" => Ok(()),
other => Err(Error::InvalidDid(format!("unsupported DID method: {other}"))),
other => Err(Error::InvalidDid(format!(
"unsupported DID method: {other}"
))),
}
}
}
@@ -121,7 +123,9 @@ impl FromStr for Did {
fn from_str(s: &str) -> Result<Self> {
if !s.starts_with("did:") {
return Err(Error::InvalidDid(format!("'{s}' does not start with 'did:'")));
return Err(Error::InvalidDid(format!(
"'{s}' does not start with 'did:'"
)));
}
let did = Self(s.to_string());
did.validate()?;
+34 -25
View File
@@ -11,14 +11,14 @@
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>
//! Signature: sig1=:base64signature:
use base64::{Engine, engine::general_purpose::STANDARD};
use base64::{engine::general_purpose::STANDARD, Engine};
use chrono::Utc;
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// The component identifiers covered by every gitlawb signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
@@ -53,16 +53,16 @@ impl HttpSignature {
let sig_input = sig_input.trim();
// Expect: sig1=("@method" "@path" "content-digest");keyid="...";alg="...";created=...
let rest = sig_input
.strip_prefix("sig1=")
.ok_or_else(|| Error::HttpSignature("Signature-Input must start with 'sig1='".into()))?;
let rest = sig_input.strip_prefix("sig1=").ok_or_else(|| {
Error::HttpSignature("Signature-Input must start with 'sig1='".into())
})?;
let open = rest.find('(').ok_or_else(|| {
Error::HttpSignature("missing '(' in Signature-Input".into())
})?;
let close = rest.find(')').ok_or_else(|| {
Error::HttpSignature("missing ')' in Signature-Input".into())
})?;
let open = rest
.find('(')
.ok_or_else(|| Error::HttpSignature("missing '(' in Signature-Input".into()))?;
let close = rest
.find(')')
.ok_or_else(|| Error::HttpSignature("missing ')' in Signature-Input".into()))?;
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
@@ -98,15 +98,19 @@ impl HttpSignature {
.trim()
.strip_prefix("sig1=:")
.and_then(|s| s.strip_suffix(':'))
.ok_or_else(|| {
Error::HttpSignature("Signature must be 'sig1=:base64:'".into())
})?;
.ok_or_else(|| Error::HttpSignature("Signature must be 'sig1=:base64:'".into()))?;
let signature_bytes = STANDARD
.decode(sig_b64)
.map_err(|e| Error::HttpSignature(format!("invalid base64 in Signature: {e}")))?;
Ok(Self { key_id, alg, created, components, signature_bytes })
Ok(Self {
key_id,
alg,
created,
components,
signature_bytes,
})
}
/// Reject if the `created` timestamp is more than 5 minutes from now.
@@ -178,12 +182,9 @@ pub fn sign_request(
request_values.insert("@path".to_string(), path_and_query.to_string());
request_values.insert("content-digest".to_string(), content_digest.clone());
let signing_string = build_signing_string(
COVERED_COMPONENTS,
sig_params_value,
&request_values,
)
.expect("required components always present when building");
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values)
.expect("required components always present when building");
let sig_bytes = keypair.sign(signing_string.as_bytes());
let sig_b64 = STANDARD.encode(sig_bytes.to_bytes());
@@ -290,10 +291,16 @@ mod tests {
request_values.insert("content-digest".to_string(), headers.content_digest.clone());
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let signing_string =
build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let vk = sig.key_id.to_verifying_key().unwrap();
let sig_b64 = headers.signature.strip_prefix("sig1=:").unwrap().strip_suffix(':').unwrap();
let sig_b64 = headers
.signature
.strip_prefix("sig1=:")
.unwrap()
.strip_suffix(':')
.unwrap();
let sig_bytes: [u8; 64] = STANDARD.decode(sig_b64).unwrap().try_into().unwrap();
assert!(verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok());
@@ -321,7 +328,9 @@ mod tests {
let kp = Keypair::generate();
let did = kp.did();
// created=1 is way in the past — should fail clock skew check
let sig_input = format!(r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#);
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#
);
let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap();
assert!(sig.check_created().is_err());
}
+13 -14
View File
@@ -3,13 +3,13 @@
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
use ed25519_dalek::{SigningKey, VerifyingKey, Signer, Signature};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::{Error, Result};
use crate::did::Did;
use crate::{Error, Result};
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
#[derive(Clone)]
@@ -47,7 +47,7 @@ impl Keypair {
/// Sign and return base64url-encoded signature string.
pub fn sign_b64(&self, msg: &[u8]) -> String {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let sig = self.sign(msg);
URL_SAFE_NO_PAD.encode(sig.to_bytes())
}
@@ -69,18 +69,13 @@ impl Keypair {
/// Load from PEM-encoded PKCS#8 private key string.
pub fn from_pem(pem: &str) -> Result<Self> {
use pkcs8::DecodePrivateKey;
let signing_key = SigningKey::from_pkcs8_pem(pem)
.map_err(|e| Error::Key(e.to_string()))?;
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|e| Error::Key(e.to_string()))?;
Ok(Self { signing_key })
}
}
/// Verify an Ed25519 signature.
pub fn verify(
verifying_key: &VerifyingKey,
msg: &[u8],
sig_bytes: &[u8; 64],
) -> Result<()> {
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
use ed25519_dalek::Verifier;
let sig = Signature::from_bytes(sig_bytes);
verifying_key
@@ -117,8 +112,8 @@ impl<T: Serialize> Signed<T> {
}
mod sig_b64 {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserializer, Serializer, Deserialize};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
@@ -126,8 +121,12 @@ mod sig_b64 {
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
let s = String::deserialize(d)?;
let bytes = URL_SAFE_NO_PAD.decode(&s).map_err(serde::de::Error::custom)?;
bytes.try_into().map_err(|_| serde::de::Error::custom("expected 64 bytes"))
let bytes = URL_SAFE_NO_PAD
.decode(&s)
.map_err(serde::de::Error::custom)?;
bytes
.try_into()
.map_err(|_| serde::de::Error::custom("expected 64 bytes"))
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
pub mod did;
pub mod cid;
pub mod identity;
pub mod http_sig;
pub mod ucan;
pub mod cert;
pub mod cid;
pub mod did;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod ucan;
pub use error::Error;
pub type Result<T> = std::result::Result<T, Error>;
+45 -23
View File
@@ -12,9 +12,9 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// A UCAN capability: what resource the token grants access to.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -30,7 +30,11 @@ pub struct Capability {
impl Capability {
pub fn new(with: impl Into<String>, can: impl Into<String>) -> Self {
Self { with: with.into(), can: can.into(), constraints: None }
Self {
with: with.into(),
can: can.into(),
constraints: None,
}
}
pub fn with_constraints(mut self, constraints: serde_json::Value) -> Self {
@@ -130,16 +134,18 @@ impl Ucan {
/// Verify the signature on this UCAN.
pub fn verify_signature(&self) -> Result<()> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use crate::identity::verify;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let vk = self.payload.iss.to_verifying_key()?;
let signing_bytes = serde_json::to_vec(&self.payload)?;
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&self.s)
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&self.s)
.map_err(|e| Error::Ucan(format!("invalid base64 signature: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
.map_err(|_| Error::Ucan("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)
@@ -148,9 +154,10 @@ impl Ucan {
/// Check if this UCAN grants a specific capability on a resource.
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload.att.iter().any(|cap| {
cap.with == resource && cap.can == action
})
self.payload
.att
.iter()
.any(|cap| cap.with == resource && cap.can == action)
}
/// Encode to a compact JSON string (the wire format).
@@ -245,7 +252,8 @@ mod tests {
audience.clone(),
vec![Capability::new("gitlawb://repos/test/repo", caps::GIT_PUSH)],
None,
).unwrap();
)
.unwrap();
ucan.verify_signature().unwrap();
assert!(!ucan.is_expired());
@@ -291,10 +299,12 @@ mod tests {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer, audience,
&issuer,
audience,
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
).unwrap();
)
.unwrap();
// Root UCAN (no proofs) should verify fine
ucan.verify_chain().unwrap();
}
@@ -307,18 +317,22 @@ mod tests {
// Alice grants Bob push access
let root = Ucan::issue(
&alice, bob.did(),
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
).unwrap();
)
.unwrap();
// Bob delegates to Charlie (with proof from Alice)
let delegated = Ucan::delegate(
&bob, charlie.did(),
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
).unwrap();
)
.unwrap();
// Chain should verify: Charlie's token → Bob's proof → Alice signed it
delegated.verify_chain().unwrap();
@@ -334,18 +348,22 @@ mod tests {
// Alice grants Bob access
let root = Ucan::issue(
&alice, bob.did(),
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
).unwrap();
)
.unwrap();
// Eve (NOT Bob) tries to delegate using Alice's proof
let bad = Ucan::delegate(
&eve, charlie.did(),
&eve,
charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
).unwrap();
)
.unwrap();
// Should fail: proof audience (Bob) != UCAN issuer (Eve)
let err = bad.verify_chain().unwrap_err();
@@ -361,17 +379,21 @@ mod tests {
// Alice grants Bob access with expiry in the past
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
let root = Ucan::issue(
&alice, bob.did(),
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
Some(exp),
).unwrap();
)
.unwrap();
let delegated = Ucan::delegate(
&bob, charlie.did(),
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
).unwrap();
)
.unwrap();
// Should fail: the proof is expired
let err = delegated.verify_chain().unwrap_err();
+84 -19
View File
@@ -8,6 +8,37 @@ use serde::{Deserialize, Serialize};
use crate::error::{AppError, Result};
use crate::state::AppState;
fn normalize_agent_did(did: &str) -> String {
if did.starts_with("did:") {
did.to_string()
} else {
format!("did:key:{did}")
}
}
fn agent_key_segment(did: &str) -> &str {
did.split(':').next_back().unwrap_or(did)
}
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
let normalized_did = normalize_agent_did(did);
if state.db.get_agent(&normalized_did).await?.is_some() {
return Ok(normalized_did);
}
let requested_key = agent_key_segment(&normalized_did);
let matching_agent = state
.db
.list_agents(None)
.await?
.into_iter()
.find(|agent| agent_key_segment(&agent.did).starts_with(requested_key));
Ok(matching_agent
.map(|agent| agent.did)
.unwrap_or(normalized_did))
}
#[derive(Debug, Serialize)]
pub struct TrustResponse {
pub did: String,
@@ -48,13 +79,16 @@ pub async fn list_agents(
Query(params): Query<AgentListQuery>,
) -> Result<Json<serde_json::Value>> {
let agents = state.db.list_agents(params.capability.as_deref()).await?;
let list: Vec<AgentResponse> = agents.into_iter().map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
}).collect();
let list: Vec<AgentResponse> = agents
.into_iter()
.map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
})
.collect();
Ok(Json(serde_json::json!({ "agents": list })))
}
@@ -63,15 +97,22 @@ pub async fn show_agent(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<(StatusCode, Json<AgentResponse>)> {
let agent = state.db.get_agent(&did).await?
.ok_or_else(|| AppError::NotFound(format!("agent {did} not found")))?;
Ok((StatusCode::OK, Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
})))
let normalized_did = resolve_agent_did(&state, &did).await?;
let agent = state
.db
.get_agent(&normalized_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {normalized_did} not found")))?;
Ok((
StatusCode::OK,
Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
}),
))
}
/// GET /api/v1/agents/{did}/trust
@@ -79,14 +120,38 @@ pub async fn get_trust(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<TrustResponse>> {
let trust_score = state.db.get_trust_score(&did).await?;
let push_count = state.db.get_push_count(&did).await?;
let normalized_did = resolve_agent_did(&state, &did).await?;
let trust_score = state.db.get_trust_score(&normalized_did).await?;
let push_count = state.db.get_push_count(&normalized_did).await?;
let level = trust_level(trust_score);
Ok(Json(TrustResponse {
did,
did: normalized_did,
trust_score,
push_count,
level,
}))
}
#[cfg(test)]
mod tests {
use super::{agent_key_segment, normalize_agent_did};
#[test]
fn normalize_agent_did_preserves_full_did() {
let did = "did:key:z6MkExample";
assert_eq!(normalize_agent_did(did), did);
}
#[test]
fn normalize_agent_did_expands_bare_key() {
assert_eq!(normalize_agent_did("z6MkExample"), "did:key:z6MkExample");
}
#[test]
fn agent_key_segment_extracts_did_key_material() {
assert_eq!(agent_key_segment("did:key:z6MkExample"), "z6MkExample");
assert_eq!(agent_key_segment("z6MkExample"), "z6MkExample");
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ pub async fn list_anchors(
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await
.map_err(|e| crate::error::AppError::Internal(e.into()))?;
.map_err(crate::error::AppError::Internal)?;
Ok(Json(serde_json::json!({
"anchors": anchors,
+117 -44
View File
@@ -80,7 +80,10 @@ pub async fn create_bounty(
}
// Verify repo exists
let _ = state.db.get_repo(&owner, &repo).await?
let _ = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let now = Utc::now().to_rfc3339();
@@ -116,12 +119,15 @@ pub async fn list_repo_bounties(
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
) -> Result<Json<serde_json::Value>> {
let bounties = state.db.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
)
.await?;
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
@@ -131,12 +137,10 @@ pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
) -> Result<Json<serde_json::Value>> {
let bounties = state.db.list_bounties(
None,
None,
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
let bounties = state
.db
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
.await?;
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
@@ -146,7 +150,10 @@ pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(bounty))
}
@@ -158,19 +165,31 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!("bounty is {}, not open", bounty.status)));
return Err(AppError::BadRequest(format!(
"bounty is {}, not open",
bounty.status
)));
}
let now = Utc::now().to_rfc3339();
state.db.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now).await?;
state
.db
.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now)
.await?;
tracing::info!(bounty_id = %id, agent = %auth.0, "bounty claimed");
let updated = state.db.get_bounty(&id).await?
let updated = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -182,11 +201,17 @@ pub async fn submit_bounty(
Path(id): Path<String>,
Json(req): Json<SubmitBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" {
return Err(AppError::BadRequest(format!("bounty is {}, not claimed", bounty.status)));
return Err(AppError::BadRequest(format!(
"bounty is {}, not claimed",
bounty.status
)));
}
if bounty.claimant_did.as_deref() != Some(&auth.0) {
return Err(AppError::BadRequest("only the claimant can submit".into()));
@@ -197,7 +222,10 @@ pub async fn submit_bounty(
tracing::info!(bounty_id = %id, pr_id = %req.pr_id, "bounty submission");
let updated = state.db.get_bounty(&id).await?
let updated = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -209,18 +237,29 @@ pub async fn approve_bounty(
Path(id): Path<String>,
Json(req): Json<ApproveBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "submitted" {
return Err(AppError::BadRequest(format!("bounty is {}, not submitted", bounty.status)));
return Err(AppError::BadRequest(format!(
"bounty is {}, not submitted",
bounty.status
)));
}
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest("only the bounty creator can approve".into()));
return Err(AppError::BadRequest(
"only the bounty creator can approve".into(),
));
}
let now = Utc::now().to_rfc3339();
state.db.approve_bounty(&id, &now, req.tx_hash.as_deref()).await?;
state
.db
.approve_bounty(&id, &now, req.tx_hash.as_deref())
.await?;
// Bump claimant trust score
if let Some(ref agent_did) = bounty.claimant_did {
@@ -231,7 +270,10 @@ pub async fn approve_bounty(
tracing::info!(bounty_id = %id, "bounty approved");
let updated = state.db.get_bounty(&id).await?
let updated = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -242,20 +284,31 @@ pub async fn cancel_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!("can only cancel open bounties, status is {}", bounty.status)));
return Err(AppError::BadRequest(format!(
"can only cancel open bounties, status is {}",
bounty.status
)));
}
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest("only the bounty creator can cancel".into()));
return Err(AppError::BadRequest(
"only the bounty creator can cancel".into(),
));
}
state.db.cancel_bounty(&id).await?;
tracing::info!(bounty_id = %id, "bounty cancelled");
let updated = state.db.get_bounty(&id).await?
let updated = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -265,11 +318,17 @@ pub async fn dispute_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state.db.get_bounty(&id).await?
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" && bounty.status != "submitted" {
return Err(AppError::BadRequest(format!("can only dispute claimed/submitted bounties, status is {}", bounty.status)));
return Err(AppError::BadRequest(format!(
"can only dispute claimed/submitted bounties, status is {}",
bounty.status
)));
}
// Check if deadline exceeded
@@ -277,7 +336,9 @@ pub async fn dispute_bounty(
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
let deadline = claimed + chrono::Duration::seconds(bounty.deadline_secs);
if Utc::now() < deadline {
return Err(AppError::BadRequest("deadline has not been exceeded yet".into()));
return Err(AppError::BadRequest(
"deadline has not been exceeded yet".into(),
));
}
}
}
@@ -285,25 +346,37 @@ pub async fn dispute_bounty(
state.db.dispute_bounty(&id).await?;
tracing::info!(bounty_id = %id, "bounty disputed — reopened");
let updated = state.db.get_bounty(&id).await?
let updated = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
/// GET /api/v1/bounties/stats
pub async fn bounty_stats(
State(state): State<AppState>,
) -> Result<Json<BountyStatsResponse>> {
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
let claimed = state.db.count_bounties_by_status("claimed").await.unwrap_or(0);
let completed = state.db.count_bounties_by_status("completed").await.unwrap_or(0);
let claimed = state
.db
.count_bounties_by_status("claimed")
.await
.unwrap_or(0);
let completed = state
.db
.count_bounties_by_status("completed")
.await
.unwrap_or(0);
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
let leaderboard = leaders.into_iter().map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
}).collect();
let leaderboard = leaders
.into_iter()
.map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
})
.collect();
Ok(Json(BountyStatsResponse {
open,
+28 -14
View File
@@ -11,21 +11,29 @@ pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs.iter().map(|c| serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})).collect();
let certs_json: Vec<serde_json::Value> = certs
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})
})
.collect();
Ok(Json(serde_json::json!({ "certificates": certs_json })))
}
@@ -36,10 +44,16 @@ pub async fn get_cert(
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
// Verify the repo exists
let _record = state.db.get_repo(&owner, &name).await?
let _record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let cert = state.db.get_ref_certificate(&id).await?
let cert = state
.db
.get_ref_certificate(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
Ok(Json(serde_json::json!({
+23 -12
View File
@@ -14,7 +14,9 @@ pub struct ChangelogQuery {
pub limit: usize,
}
fn default_limit() -> usize { 20 }
fn default_limit() -> usize {
20
}
/// GET /api/v1/repos/:owner/:repo/changelog[?limit=N]
///
@@ -26,27 +28,36 @@ pub async fn get_changelog(
Path((owner, repo)): Path<(String, String)>,
Query(query): Query<ChangelogQuery>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let limit = query.limit.min(100);
// ── Commits from git log ─────────────────────────────────────────────
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let commits = store::log(&disk_path, &head_ref, limit).unwrap_or_default();
let mut events: Vec<serde_json::Value> = commits.into_iter().map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
let mut events: Vec<serde_json::Value> = commits
.into_iter()
.map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
})
})
}).collect();
.collect();
// ── Merged PRs ───────────────────────────────────────────────────────
let prs = state.db.list_prs(&record.id).await.unwrap_or_default();
+72 -48
View File
@@ -13,28 +13,36 @@ pub async fn list_ref_updates(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>> {
let limit = params.get("limit")
let limit = params
.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(50)
.min(200);
let updates = state.db.list_ref_updates(limit).await?;
let events: Vec<serde_json::Value> = updates.iter().map(|u| serde_json::json!({
"id": u.id,
"node_did": u.node_did,
"pusher_did": u.pusher_did,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
})).collect();
let events: Vec<serde_json::Value> = updates
.iter()
.map(|u| {
serde_json::json!({
"id": u.id,
"node_did": u.node_did,
"pusher_did": u.pusher_did,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
})
})
.collect();
let count = events.len();
Ok(Json(serde_json::json!({ "events": events, "count": count })))
Ok(Json(
serde_json::json!({ "events": events, "count": count }),
))
}
/// GET /api/v1/repos/{owner}/{repo}/events
@@ -43,7 +51,8 @@ pub async fn list_repo_events(
Path((owner, repo_name)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>> {
let limit = params.get("limit")
let limit = params
.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(50)
.min(200);
@@ -58,7 +67,11 @@ pub async fn list_repo_events(
let repo_id_str = if let Some(ref record) = repo_record {
format!(
"{}/{}",
record.owner_did.split(':').last().unwrap_or(&record.owner_did),
record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did),
repo_name
)
} else {
@@ -67,46 +80,55 @@ pub async fn list_repo_events(
// Fetch local ref certificates for this repo (if the repo exists on this node)
let cert_events: Vec<serde_json::Value> = if let Some(ref record) = repo_record {
state.db.list_ref_certificates(&record.id).await
state
.db
.list_ref_certificates(&record.id)
.await
.unwrap_or_default()
.iter()
.map(|c| serde_json::json!({
"type": "local_cert",
"id": c.id,
"repo": repo_id_str,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"timestamp": c.issued_at,
"source": "local",
}))
.map(|c| {
serde_json::json!({
"type": "local_cert",
"id": c.id,
"repo": repo_id_str,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"timestamp": c.issued_at,
"source": "local",
})
})
.collect()
} else {
vec![]
};
// Fetch gossipsub received ref updates for this repo (uses full slug built above)
let gossip_events: Vec<serde_json::Value> = state.db
.list_repo_ref_updates(&repo_id_str, limit).await
let gossip_events: Vec<serde_json::Value> = state
.db
.list_repo_ref_updates(&repo_id_str, limit)
.await
.unwrap_or_default()
.iter()
.map(|u| serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"source": "gossipsub",
}))
.map(|u| {
serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"source": "gossipsub",
})
})
.collect();
// Merge both lists
@@ -124,5 +146,7 @@ pub async fn list_repo_events(
all_events.truncate(limit as usize);
let count = all_events.len();
Ok(Json(serde_json::json!({ "events": all_events, "count": count })))
Ok(Json(
serde_json::json!({ "events": all_events, "count": count }),
))
}
+7 -7
View File
@@ -50,8 +50,9 @@ pub async fn get_by_cid(
// 2. Search all repos for an object with this SHA-256
let repos = state
.db
.list_all_repos().await
.map_err(|e| AppError::Internal(e.into()))?;
.list_all_repos()
.await
.map_err(AppError::Internal)?;
for repo in &repos {
let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
@@ -99,13 +100,12 @@ pub async fn get_by_cid(
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>> {
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
let pins = state
.db
.list_pinned_cids().await
.map_err(|e| AppError::Internal(e.into()))?;
.list_pinned_cids()
.await
.map_err(AppError::Internal)?;
Ok(Json(serde_json::json!({
"pins": pins,
+50 -15
View File
@@ -10,8 +10,8 @@ use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::IssueComment;
use crate::error::{AppError, Result};
use crate::state::AppState;
use crate::git::issues as git_issues;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateIssueRequest {
@@ -39,7 +39,10 @@ pub async fn create_issue(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<CreateIssueRequest>,
) -> Result<(StatusCode, Json<IssueRecord>)> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let issue_id = Uuid::new_v4().to_string();
@@ -58,7 +61,10 @@ pub async fn create_issue(
let json_str = serde_json::to_string(&issue)
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
@@ -85,14 +91,20 @@ pub async fn list_issues(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let raw_issues = git_issues::list_issues(&disk_path)
.map_err(|e| AppError::Git(e.to_string()))?;
let raw_issues =
git_issues::list_issues(&disk_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut issues: Vec<serde_json::Value> = Vec::new();
for raw in raw_issues {
@@ -109,10 +121,16 @@ pub async fn get_issue(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let raw = git_issues::get_issue(&disk_path, &issue_id)
@@ -138,13 +156,21 @@ pub async fn create_issue_comment(
Json(req): Json<CreateIssueCommentRequest>,
) -> Result<(StatusCode, Json<IssueComment>)> {
if req.body.trim().is_empty() {
return Err(AppError::BadRequest("comment body must not be empty".into()));
return Err(AppError::BadRequest(
"comment body must not be empty".into(),
));
}
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
// Verify issue exists
crate::git::issues::get_issue(&disk_path, &issue_id)
@@ -168,7 +194,10 @@ pub async fn list_issue_comments(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let _record = state.db.get_repo(&owner, &repo).await?
let _record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let comments = state.db.list_issue_comments(&issue_id).await?;
@@ -181,10 +210,16 @@ pub async fn close_issue(
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
+25 -6
View File
@@ -25,18 +25,31 @@ pub async fn add_label(
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
if !label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == ':') {
if !label
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == ':')
{
return Err(AppError::BadRequest(
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let added = state.db.add_label(&record.id, &label).await?;
let status = if added { StatusCode::CREATED } else { StatusCode::OK };
Ok((status, Json(serde_json::json!({ "label": label, "added": added }))))
let status = if added {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((
status,
Json(serde_json::json!({ "label": label, "added": added })),
))
}
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
@@ -45,7 +58,10 @@ pub async fn remove_label(
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
state.db.remove_label(&record.id, &label).await?;
@@ -57,7 +73,10 @@ pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let labels = state.db.list_labels(&record.id).await?;
+1
View File
@@ -11,6 +11,7 @@ pub mod peers;
pub mod protect;
pub mod pulls;
pub mod register;
pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
+45 -35
View File
@@ -50,7 +50,9 @@ pub async fn list_peers(State(state): State<AppState>) -> Result<Json<serde_json
reachable: p.last_ping_ok,
})
.collect();
Ok(Json(serde_json::json!({ "peers": resp, "count": resp.len() })))
Ok(Json(
serde_json::json!({ "peers": resp, "count": resp.len() }),
))
}
/// POST /api/v1/peers/announce (auth required)
@@ -63,7 +65,9 @@ pub async fn announce(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
// Validate the URL is HTTP/HTTPS
if !req.http_url.starts_with("http://") && !req.http_url.starts_with("https://") {
return Err(AppError::BadRequest("http_url must start with http:// or https://".into()));
return Err(AppError::BadRequest(
"http_url must start with http:// or https://".into(),
));
}
state.db.upsert_peer(&req.did, &req.http_url).await?;
@@ -71,13 +75,16 @@ pub async fn announce(
tracing::info!(did = %req.did, url = %req.http_url, "peer announced");
// Return our own info so the peer can add us back
Ok((StatusCode::OK, Json(serde_json::json!({
"status": "accepted",
"node_did": state.node_did.to_string(),
"node_url": state.config.public_url.as_deref().unwrap_or(""),
"peer_count": state.db.list_peers().await.map(|p| p.len()).unwrap_or(0),
"message": "added to peer list",
}))))
Ok((
StatusCode::OK,
Json(serde_json::json!({
"status": "accepted",
"node_did": state.node_did.to_string(),
"node_url": state.config.public_url.as_deref().unwrap_or(""),
"peer_count": state.db.list_peers().await.map(|p| p.len()).unwrap_or(0),
"message": "added to peer list",
})),
))
}
/// POST /api/v1/sync/trigger
@@ -86,9 +93,7 @@ pub async fn announce(
/// peer's repo list over HTTP and enqueues any repos we don't have or are
/// behind on into the sync_queue. This is the HTTP fallback when Gossipsub
/// p2p is not yet connected.
pub async fn trigger_sync(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>> {
pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
let peers = state.db.list_peers().await?;
let client = &state.http_client;
let mut enqueued = 0u32;
@@ -99,10 +104,8 @@ pub async fn trigger_sync(
continue;
}
let url = format!("{}/api/v1/repos", peer.http_url.trim_end_matches('/'));
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.get(&url).send(),
).await;
let result =
tokio::time::timeout(std::time::Duration::from_secs(5), client.get(&url).send()).await;
let repos: Vec<serde_json::Value> = match result {
Ok(Ok(resp)) if resp.status().is_success() => {
@@ -116,22 +119,27 @@ pub async fn trigger_sync(
};
for repo in repos {
let repo_slug = match (repo.get("owner_did").and_then(|v| v.as_str()),
repo.get("name").and_then(|v| v.as_str())) {
let repo_slug = match (
repo.get("owner_did").and_then(|v| v.as_str()),
repo.get("name").and_then(|v| v.as_str()),
) {
(Some(owner), Some(name)) => {
// Use short owner (last colon segment) matching DB convention
let short = owner.split(':').last().unwrap_or(owner);
let short = owner.split(':').next_back().unwrap_or(owner);
format!("{short}/{name}")
}
_ => continue,
};
let _ = state.db.enqueue_sync(
&repo_slug,
&peer.did,
"refs/heads/main",
"0000000000000000000000000000000000000000",
None,
).await;
let _ = state
.db
.enqueue_sync(
&repo_slug,
&peer.did,
"refs/heads/main",
"0000000000000000000000000000000000000000",
None,
)
.await;
enqueued += 1;
}
}
@@ -171,13 +179,10 @@ pub async fn notify_sync(
)));
}
state.db.enqueue_sync(
&req.repo,
&req.node_did,
&req.ref_name,
&req.new_sha,
None,
).await?;
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
.await?;
tracing::info!(
repo = %req.repo,
@@ -201,12 +206,17 @@ pub async fn ping_peer(
Path(did): Path<String>,
) -> Result<Json<serde_json::Value>> {
let peers = state.db.list_peers().await?;
let peer = peers.into_iter().find(|p| p.did == did)
let peer = peers
.into_iter()
.find(|p| p.did == did)
.ok_or_else(|| AppError::RepoNotFound(format!("peer {did} not found")))?;
// Async ping
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false);
let ok = reqwest::get(&url)
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
let _ = state.db.mark_peer_ping(&did, ok).await;
+30 -10
View File
@@ -17,12 +17,19 @@ pub async fn protect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Only the repo owner can protect branches
let caller = &auth.0;
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can protect branches".into(),
@@ -33,11 +40,14 @@ pub async fn protect_branch(
tracing::info!(repo = %repo, branch = %branch, caller = %caller, "branch protected");
Ok((StatusCode::CREATED, Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
}))))
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
})),
))
}
/// DELETE /api/v1/repos/:owner/:repo/branches/:branch/protect
@@ -46,11 +56,18 @@ pub async fn unprotect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can unprotect branches".into(),
@@ -73,7 +90,10 @@ pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let branches = state.db.list_protected_branches(&record.id).await?;
+105 -27
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::{PullRequest, PrComment, PrReview};
use crate::db::{PrComment, PrReview, PullRequest};
use crate::error::{AppError, Result};
use crate::git::store;
use crate::state::AppState;
@@ -40,11 +40,16 @@ pub async fn create_pr(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreatePrRequest>,
) -> Result<(StatusCode, Json<PullRequest>)> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let author_did = auth.0;
let target_branch = req.target_branch.unwrap_or_else(|| record.default_branch.clone());
let target_branch = req
.target_branch
.unwrap_or_else(|| record.default_branch.clone());
let number = state.db.next_pr_number(&record.id).await?;
let now = Utc::now().to_rfc3339();
@@ -68,7 +73,11 @@ pub async fn create_pr(
// Bump trust score for the PR author — increment current score by 0.05
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only open PRs)
let current = state.db.get_trust_score(&pr.author_did).await.unwrap_or(0.05);
let current = state
.db
.get_trust_score(&pr.author_did)
.await
.unwrap_or(0.05);
let new_score = (current + 0.05).min(1.0);
let _ = state.db.update_trust_score(&pr.author_did, new_score).await;
@@ -93,11 +102,16 @@ pub async fn list_prs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let prs = state.db.list_prs(&record.id).await?;
Ok(Json(serde_json::json!({ "pulls": prs, "count": prs.len() })))
Ok(Json(
serde_json::json!({ "pulls": prs, "count": prs.len() }),
))
}
/// GET /api/v1/repos/:owner/:repo/pulls/:number
@@ -105,10 +119,16 @@ pub async fn get_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<PullRequest>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
Ok(Json(pr))
@@ -119,13 +139,22 @@ pub async fn get_pr_diff(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let diff = store::branch_diff(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
@@ -143,21 +172,36 @@ pub async fn merge_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
if pr.status != "open" {
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
let merge_result = store::merge_branch(&disk_path, &pr.target_branch, &pr.source_branch, &merger_did, &pr.title);
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
&pr.source_branch,
&merger_did,
&pr.title,
);
// Always release the advisory lock — even on error
guard.release().await;
@@ -194,10 +238,16 @@ pub async fn close_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
state.db.close_pr(&pr.id).await?;
@@ -224,15 +274,23 @@ pub async fn create_review(
Path((owner, name, number)): Path<(String, String, i64)>,
Json(req): Json<CreateReviewRequest>,
) -> Result<(StatusCode, Json<PrReview>)> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let valid_statuses = ["approved", "changes_requested", "comment"];
if !valid_statuses.contains(&req.status.as_str()) {
return Err(AppError::BadRequest("status must be approved, changes_requested, or comment".into()));
return Err(AppError::BadRequest(
"status must be approved, changes_requested, or comment".into(),
));
}
let review = PrReview {
@@ -267,10 +325,16 @@ pub async fn list_reviews(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let reviews = state.db.list_pr_reviews(&pr.id).await?;
@@ -285,13 +349,21 @@ pub async fn create_comment(
Json(req): Json<CreateCommentRequest>,
) -> Result<(StatusCode, Json<PrComment>)> {
if req.body.trim().is_empty() {
return Err(AppError::BadRequest("comment body must not be empty".into()));
return Err(AppError::BadRequest(
"comment body must not be empty".into(),
));
}
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comment = PrComment {
@@ -312,10 +384,16 @@ pub async fn list_comments(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state.db.get_pr(&record.id, number).await?
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comments = state.db.list_pr_comments(&pr.id).await?;
+26 -16
View File
@@ -1,8 +1,8 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use gitlawb_core::did::Did;
use gitlawb_core::ucan::Ucan;
@@ -38,35 +38,45 @@ pub async fn register(
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>)> {
// Parse and validate the DID
let agent_did: Did = req.did.parse()
let agent_did: Did = req
.did
.parse()
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
// Store the agent in the local index
state.db.register_agent(agent_did.as_str(), &req.capabilities).await?;
state
.db
.register_agent(agent_did.as_str(), &req.capabilities)
.await?;
// Grant a small baseline trust score on first registration (verified via HTTP Signature).
// Score grows further with pushes, PRs, and issue activity.
let initial_trust = 0.05;
let _ = state.db.update_trust_score(agent_did.as_str(), initial_trust).await;
let _ = state
.db
.update_trust_score(agent_did.as_str(), initial_trust)
.await;
// Issue a bootstrap UCAN from the node's identity
let ucan = Ucan::bootstrap(&state.node_keypair, agent_did.clone())
.map_err(|e| AppError::Internal(e.into()))?;
let exp = Utc::now() + chrono::Duration::days(30);
let ucan_encoded = ucan.encode()
.map_err(|e| AppError::Internal(e.into()))?;
let ucan_encoded = ucan.encode().map_err(|e| AppError::Internal(e.into()))?;
tracing::info!(did = %agent_did, "registered new agent");
Ok((StatusCode::CREATED, Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
})))
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
}),
))
}
+202
View File
@@ -0,0 +1,202 @@
//! Repo replica registration API.
//!
//! Lets a node tell the origin "I'm hosting a replica of your repo." The
//! origin records the (replica DID, replica URL) pair and exposes the list
//! publicly so anyone can see how many nodes are mirroring a given repo.
//!
//! Endpoints:
//! - `PUT /api/v1/repos/:owner/:repo/replicas` (auth) register
//! - `DELETE /api/v1/repos/:owner/:repo/replicas` (auth) unregister
//! - `GET /api/v1/repos/:owner/:repo/replicas` (public) list
//!
//! Auth model: the caller's DID (verified via HTTP Signatures) is the
//! replica's identity. There's no separate "claim this URL" check — replicas
//! self-report their public URL. Operators viewing the list should treat
//! replica URLs as advisory until they actually reach out and verify.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct RegisterReplicaRequest {
/// Publicly reachable URL of the replica node (e.g. `https://my-node.example.com`).
pub url: String,
}
/// PUT /api/v1/repos/:owner/:repo/replicas
/// Idempotent — first registration returns 201, subsequent ones update the URL and return 200.
pub async fn register_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RegisterReplicaRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
validate_replica_url(&req.url)?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
// Don't let an owner register themselves as a replica of their own repo.
if replica_did == &record.owner_did {
return Err(AppError::BadRequest(
"the repo owner is not a replica of their own repo".into(),
));
}
let inserted = state
.db
.register_replica(&record.id, replica_did, &req.url)
.await?;
let count = state.db.count_replicas(&record.id).await?;
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
tracing::info!(
repo = %repo,
replica = %replica_did,
url = %req.url,
"replica registered"
);
Ok((
status,
Json(serde_json::json!({
"status": "registered",
"repo": format!("{owner}/{repo}"),
"replica_did": replica_did,
"replica_url": req.url,
"replica_count": count,
})),
))
}
/// DELETE /api/v1/repos/:owner/:repo/replicas
/// Idempotent — no error if the caller wasn't registered.
pub async fn unregister_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
state.db.unregister_replica(&record.id, replica_did).await?;
let count = state.db.count_replicas(&record.id).await?;
tracing::info!(repo = %repo, replica = %replica_did, "replica unregistered");
Ok(Json(serde_json::json!({
"status": "unregistered",
"repo": format!("{owner}/{repo}"),
"replica_count": count,
})))
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Public — returns the list of replicas (DID + URL + registration timestamp).
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replicas = state.db.list_replicas(&record.id).await?;
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"replica_count": replicas.len(),
"replicas": replicas,
})))
}
/// Basic URL hygiene — must be http(s), parseable, length-bounded.
fn validate_replica_url(url: &str) -> Result<()> {
if url.is_empty() {
return Err(AppError::BadRequest("replica url is empty".into()));
}
if url.len() > 512 {
return Err(AppError::BadRequest("replica url exceeds 512 chars".into()));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(AppError::BadRequest(
"replica url must start with http:// or https://".into(),
));
}
// No spaces / control chars / newlines.
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(AppError::BadRequest(
"replica url contains whitespace or control characters".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_accepts_normal_https() {
validate_replica_url("https://node.example.com").unwrap();
validate_replica_url("https://my-node.example.com:7545").unwrap();
validate_replica_url("http://localhost:7545").unwrap();
}
#[test]
fn url_rejects_empty() {
assert!(validate_replica_url("").is_err());
}
#[test]
fn url_rejects_non_http_scheme() {
for bad in [
"ftp://host",
"file:///etc/passwd",
"javascript:alert(1)",
"/path",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_whitespace_and_control() {
for bad in [
"https://host .com",
"https://host\n.com",
"https://host\t.com",
"https://host\0evil.com",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_overlong() {
let long = format!("https://{}.com", "a".repeat(600));
assert!(validate_replica_url(&long).is_err());
}
}
+316 -119
View File
@@ -1,19 +1,19 @@
use std::sync::Arc;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use axum::response::Response;
use axum::Json;
use bytes::Bytes;
use std::sync::Arc;
use crate::auth::AuthenticatedDid;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cert;
use crate::error::{AppError, Result};
use crate::git::{smart_http, store};
use crate::state::AppState;
use crate::git::{store, smart_http};
use crate::webhooks;
// ── Request / Response types ───────────────────────────────────────────────
@@ -28,8 +28,12 @@ pub struct CreateRepoRequest {
pub default_branch: String,
}
fn default_true() -> bool { true }
fn default_main() -> String { "main".to_string() }
fn default_true() -> bool {
true
}
fn default_main() -> String {
"main".to_string()
}
#[derive(Debug, Serialize)]
pub struct RepoResponse {
@@ -61,7 +65,11 @@ pub async fn create_repo(
Json(req): Json<CreateRepoRequest>,
) -> Result<(StatusCode, Json<RepoResponse>)> {
// Sanitize name: alphanumeric, hyphens, underscores only
if !req.name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
if !req
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
@@ -75,7 +83,10 @@ pub async fn create_repo(
return Err(AppError::RepoExists(req.name));
}
let disk_path = state.repo_store.init(&owner_did, &req.name).await
let disk_path = state
.repo_store
.init(&owner_did, &req.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let now = Utc::now();
@@ -103,20 +114,52 @@ pub async fn create_repo(
#[derive(Debug, Deserialize)]
pub struct ListReposQuery {
/// Filter by owner DID key segment (short form after last colon)
/// Filter by owner DID key segment (short form after last colon) or full DID.
pub owner: Option<String>,
/// Page size. If omitted, the legacy "return all rows" path is used so existing
/// peer/CLI callers stay backwards-compatible. Capped at 200 when provided.
pub limit: Option<i64>,
/// Row offset. Ignored unless `limit` is also provided.
#[serde(default)]
pub offset: Option<i64>,
}
/// GET /api/v1/repos[?owner=<short>]
/// List public repositories on this node, optionally filtered by owner.
/// GET /api/v1/repos[?owner=<short>][&limit=&offset=]
///
/// Lists repositories on this node, optionally filtered by owner. When `limit` is
/// present, returns one page and the `X-Total-Count` response header carries the
/// total matching row count. Without `limit`, falls back to returning every row
/// (kept for backwards compat with peer sync and existing CLI tooling).
pub async fn list_repos(
State(state): State<AppState>,
Query(query): Query<ListReposQuery>,
) -> Result<Json<Vec<RepoResponse>>> {
let repos = state.db.list_all_repos().await?;
) -> Result<Response> {
use axum::http::HeaderValue;
use axum::response::IntoResponse;
if let Some(raw_limit) = query.limit {
let limit = raw_limit.clamp(1, 200);
let offset = query.offset.unwrap_or(0).max(0);
let (rows, total) = state
.db
.list_all_repos_paged(query.owner.as_deref(), limit, offset)
.await?;
let body: Vec<RepoResponse> = rows
.into_iter()
.map(|(r, stars)| to_response(&r, &state, stars))
.collect();
let mut response = Json(body).into_response();
response.headers_mut().insert(
"X-Total-Count",
HeaderValue::from_str(&total.to_string()).unwrap_or(HeaderValue::from_static("0")),
);
return Ok(response);
}
let repos = state.db.list_all_repos_with_stars().await?;
let filtered: Vec<_> = repos
.iter()
.filter(|r| {
.filter(|(r, _)| {
if let Some(owner) = &query.owner {
let short = r.owner_did.split(':').next_back().unwrap_or(&r.owner_did);
short == owner.as_str() || r.owner_did == owner.as_str()
@@ -125,12 +168,17 @@ pub async fn list_repos(
}
})
.collect();
let mut resp = Vec::with_capacity(filtered.len());
for r in filtered {
let count = state.db.count_stars(&r.id).await.unwrap_or(0);
resp.push(to_response(r, &state, count));
}
Ok(Json(resp))
let total = filtered.len() as i64;
let resp: Vec<_> = filtered
.into_iter()
.map(|(r, stars)| to_response(r, &state, *stars))
.collect();
let mut response = Json(resp).into_response();
response.headers_mut().insert(
"X-Total-Count",
HeaderValue::from_str(&total.to_string()).unwrap_or(HeaderValue::from_static("0")),
);
Ok(response)
}
/// GET /api/v1/repos/:owner/:repo
@@ -138,7 +186,10 @@ pub async fn get_repo(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<RepoResponse>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let count = state.db.count_stars(&record.id).await.unwrap_or(0);
Ok(Json(to_response(&record, &state, count)))
@@ -149,10 +200,16 @@ pub async fn list_commits(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let commits = store::log(&disk_path, &head_ref, 30).unwrap_or_default();
@@ -165,13 +222,19 @@ pub async fn get_blob(
State(state): State<AppState>,
Path((owner, name, file_path)): Path<(String, String, String)>,
) -> Result<Response> {
use axum::response::IntoResponse;
use axum::http::header;
use axum::response::IntoResponse;
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let content = store::read_file(&disk_path, &head_ref, &file_path)
@@ -180,12 +243,13 @@ pub async fn get_blob(
// Guess content type
let mime = match file_path.rsplit('.').next() {
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") => "application/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") => "application/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("md") => "text/markdown; charset=utf-8",
Some("rs") | Some("py") | Some("ts") | Some("sh") | Some("txt") | Some("toml") | Some("yaml") | Some("yml") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
Some("md") => "text/markdown; charset=utf-8",
Some("rs") | Some("py") | Some("ts") | Some("sh") | Some("txt") | Some("toml")
| Some("yaml") | Some("yml") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
};
Ok(([(header::CONTENT_TYPE, mime)], content).into_response())
@@ -196,10 +260,16 @@ pub async fn get_tree_root(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let entries = store::ls_tree(&disk_path, &head_ref, "").unwrap_or_default();
@@ -212,15 +282,23 @@ pub async fn get_tree(
State(state): State<AppState>,
Path((owner, name, tree_path)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let entries = store::ls_tree(&disk_path, &head_ref, &tree_path).unwrap_or_default();
Ok(Json(serde_json::json!({ "entries": entries, "path": tree_path })))
Ok(Json(
serde_json::json!({ "entries": entries, "path": tree_path }),
))
}
// ── Git smart HTTP endpoints ──────────────────────────────────────────────
@@ -233,20 +311,31 @@ pub async fn git_info_refs(
) -> Result<Response> {
let name = repo.trim_end_matches(".git");
tracing::info!(owner = %owner, repo = %name, "info/refs request");
let record = state.db.get_repo(&owner, name).await?
let record = state
.db
.get_repo(&owner, name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let service = query.service
let service = query
.service
.ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?;
tracing::debug!(service = %service, repo = %name, "info/refs service");
// For receive-pack (push), download the latest from Tigris so the client
// sees the same refs that acquire_write() will operate on.
let disk_path = if service == "git-receive-pack" {
state.repo_store.acquire_fresh(&record.owner_did, &record.name).await
state
.repo_store
.acquire_fresh(&record.owner_did, &record.name)
.await
} else {
state.repo_store.acquire(&record.owner_did, &record.name).await
}.map_err(|e| {
state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
}
.map_err(|e| {
tracing::error!(repo = %name, service = %service, err = %e, "repo acquire failed");
AppError::Git(e.to_string())
})?;
@@ -266,10 +355,16 @@ pub async fn git_upload_pack(
body: Bytes,
) -> Result<Response> {
let name = repo.trim_end_matches(".git");
let record = state.db.get_repo(&owner, name).await?
let record = state
.db
.get_repo(&owner, name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
smart_http::upload_pack(&disk_path, body)
.await
@@ -294,24 +389,41 @@ pub async fn git_receive_pack(
) -> Result<Response> {
let name = repo.trim_end_matches(".git");
tracing::info!(owner = %owner, repo = %name, "receive-pack request");
let record = state.db.get_repo(&owner, name).await?
let record = state
.db
.get_repo(&owner, name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// Parse ref updates from pkt-line body before handing to git
let ref_updates = parse_ref_updates(&body);
tracing::debug!(ref_count = ref_updates.len(), "parsed ref updates from pack");
tracing::debug!(
ref_count = ref_updates.len(),
"parsed ref updates from pack"
);
// ── Branch protection check ──────────────────────────────────────────
let pusher_did_for_check = extract_did_from_auth(&headers);
tracing::debug!(pusher_did = ?pusher_did_for_check, "extracted pusher DID from auth headers");
for update in &ref_updates {
// Strip refs/heads/ prefix to get plain branch name
let branch = update.ref_name
let branch = update
.ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&update.ref_name);
if state.db.is_branch_protected(&record.id, branch).await.unwrap_or(false) {
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
let is_owner = pusher_did_for_check.as_deref()
if state
.db
.is_branch_protected(&record.id, branch)
.await
.unwrap_or(false)
{
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
let is_owner = pusher_did_for_check
.as_deref()
.map(|did| did == record.owner_did || did == owner_short)
.unwrap_or(false);
if !is_owner {
@@ -329,7 +441,10 @@ pub async fn git_receive_pack(
}
tracing::debug!(repo = %name, "acquiring write lock");
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| {
tracing::error!(repo = %name, err = %e, "acquire_write failed");
AppError::Git(e.to_string())
@@ -354,7 +469,8 @@ pub async fn git_receive_pack(
let pusher_did = extract_did_from_auth(&headers);
if let Some(ref did) = pusher_did {
// Use the first new commit hash we parsed, fall back to timestamp
let commit_hash = ref_updates.first()
let commit_hash = ref_updates
.first()
.map(|u| u.new_sha.clone())
.unwrap_or_else(|| Utc::now().timestamp().to_string());
@@ -366,34 +482,39 @@ pub async fn git_receive_pack(
let _ = state.db.update_trust_score(did, new_score).await;
}
let ref_name = ref_updates.first()
let ref_name = ref_updates
.first()
.map(|u| u.ref_name.as_str())
.unwrap_or("refs/heads/main");
let old_sha = ref_updates.first()
let old_sha = ref_updates
.first()
.map(|u| u.old_sha.as_str())
.unwrap_or("0000000000000000000000000000000000000000");
// Issue a signed ref-update certificate
match cert::issue_ref_certificate(
&state,
&record.id,
ref_name,
old_sha,
&commit_hash,
did,
).await {
Ok(c) => tracing::info!(cert_id = %c.id, repo = %record.name, pusher = %did, "issued ref certificate"),
match cert::issue_ref_certificate(&state, &record.id, ref_name, old_sha, &commit_hash, did)
.await
{
Ok(c) => {
tracing::info!(cert_id = %c.id, repo = %record.name, pusher = %did, "issued ref certificate")
}
Err(e) => tracing::warn!(err = %e, "failed to issue ref certificate"),
}
}
// Fire push webhooks — one per ref update
if !ref_updates.is_empty() {
let base_url = state.config.public_url
let base_url = state
.config
.public_url
.as_deref()
.unwrap_or("http://127.0.0.1:7545")
.trim_end_matches('/');
let owner_short = record.owner_did.split(':').last().unwrap_or(&record.owner_did);
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
let clone_url = format!("{}/{}/{}.git", base_url, owner_short, record.name);
for update in &ref_updates {
@@ -429,7 +550,8 @@ pub async fn git_receive_pack(
let repo_path_clone = disk_path.clone();
let db_clone = state.db.clone();
tokio::spawn(async move {
let pinned = crate::ipfs_pin::pin_new_objects(&ipfs_api, &repo_path_clone, &db_clone).await;
let pinned =
crate::ipfs_pin::pin_new_objects(&ipfs_api, &repo_path_clone, &db_clone).await;
if !pinned.is_empty() {
tracing::info!(count = pinned.len(), "pinned git objects to IPFS");
for (sha, cid) in &pinned {
@@ -447,8 +569,17 @@ pub async fn git_receive_pack(
let db_clone = state.db.clone();
let http_client = Arc::clone(&state.http_client);
let node_did_str = state.node_did.to_string();
let repo_slug = format!("{}/{}", record.owner_did.split(':').last().unwrap_or(&record.owner_did), record.name);
let ref_updates_clone = ref_updates.iter()
let repo_slug = format!(
"{}/{}",
record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did),
record.name
);
let ref_updates_clone = ref_updates
.iter()
.map(|u| (u.ref_name.clone(), u.new_sha.clone()))
.collect::<Vec<_>>();
let p2p_handle = state.p2p.clone();
@@ -457,6 +588,7 @@ pub async fn git_receive_pack(
let ref_update_tx = state.ref_update_tx.clone();
let irys_url = state.config.irys_url.clone();
let owner_did_for_arweave = record.owner_did.clone();
let self_public_url = state.config.public_url.clone();
tokio::spawn(async move {
let pinned = crate::pinata::pin_new_objects(
&http_client,
@@ -472,17 +604,16 @@ pub async fn git_receive_pack(
}
// Build sha→cid map from pinned objects
let cid_map: std::collections::HashMap<String, String> =
pinned.into_iter().collect();
let cid_map: std::collections::HashMap<String, String> = pinned.into_iter().collect();
// Record branch→CID for each ref update and publish gossip
for (ref_name, new_sha) in &ref_updates_clone {
let cid = cid_map.get(new_sha).map(|s| s.as_str());
if let Some(cid_str) = cid {
let _ = db_clone.upsert_branch_cid(
&repo_slug, ref_name, new_sha, cid_str, &node_did_str,
).await;
let _ = db_clone
.upsert_branch_cid(&repo_slug, ref_name, new_sha, cid_str, &node_did_str)
.await;
}
if let Some(p2p) = &p2p_handle {
@@ -496,7 +627,8 @@ pub async fn git_receive_pack(
timestamp: chrono::Utc::now().to_rfc3339(),
cert_id: None,
cid: cid.map(|s| s.to_string()),
}).await;
})
.await;
}
}
@@ -504,24 +636,35 @@ pub async fn git_receive_pack(
// This is the reliable fallback when Gossipsub p2p is not yet connected.
if let Ok(peers) = db_for_peers.list_peers().await {
for peer in peers {
if peer.http_url.is_empty() { continue; }
let notify_url = format!(
"{}/api/v1/sync/notify",
peer.http_url.trim_end_matches('/')
);
if peer.http_url.is_empty() {
continue;
}
let peer_url = peer.http_url.trim_end_matches('/');
if let Some(self_url) = self_public_url.as_deref() {
if peer_url == self_url.trim_end_matches('/') {
continue;
}
}
let notify_url = format!("{peer_url}/api/v1/sync/notify");
let body = serde_json::json!({
"repo": repo_slug,
"ref_name": ref_updates_clone.first().map(|(r, _)| r).unwrap_or(&String::new()),
"new_sha": ref_updates_clone.first().map(|(_, s)| s).unwrap_or(&String::new()),
"node_did": node_did_str,
"pusher_did": pusher_did_clone,
"old_sha": "0000000000000000000000000000000000000000",
"timestamp": chrono::Utc::now().to_rfc3339(),
});
match http_client.post(&notify_url).json(&body).send().await {
Ok(r) if r.status().is_success() =>
tracing::info!(peer = %peer.did, repo = %repo_slug, "notified peer to sync"),
Ok(r) =>
tracing::warn!(peer = %peer.did, status = %r.status(), "peer sync notify returned error"),
Err(e) =>
tracing::warn!(peer = %peer.did, err = %e, "failed to notify peer"),
Ok(r) if r.status().is_success() => {
tracing::info!(peer = %peer.did, repo = %repo_slug, "notified peer to sync")
}
Ok(r) => {
tracing::warn!(peer = %peer.did, status = %r.status(), "peer sync notify returned error")
}
Err(e) => {
tracing::warn!(peer = %peer.did, err = %e, "failed to notify peer")
}
}
}
}
@@ -530,9 +673,15 @@ pub async fn git_receive_pack(
let now_ts = chrono::Utc::now().to_rfc3339();
let _ = ref_update_tx.send(crate::state::RefUpdateBroadcast {
repo: repo_slug.clone(),
ref_name: ref_updates_clone.first().map(|(r, _)| r.clone()).unwrap_or_default(),
ref_name: ref_updates_clone
.first()
.map(|(r, _)| r.clone())
.unwrap_or_default(),
old_sha: "0000000000000000000000000000000000000000".to_string(),
new_sha: ref_updates_clone.first().map(|(_, s)| s.clone()).unwrap_or_default(),
new_sha: ref_updates_clone
.first()
.map(|(_, s)| s.clone())
.unwrap_or_default(),
pusher_did: pusher_did_clone.clone(),
node_did: node_did_str.clone(),
timestamp: now_ts.clone(),
@@ -552,13 +701,23 @@ pub async fn git_receive_pack(
timestamp: now_ts.clone(),
node_did: node_did_str.clone(),
};
match crate::arweave::anchor_ref_update(&http_client, &irys_url, &anchor).await {
match crate::arweave::anchor_ref_update(&http_client, &irys_url, &anchor).await
{
Ok(tx_id) if !tx_id.is_empty() => {
let arweave_url = crate::arweave::arweave_url(&tx_id);
let _ = db_clone.record_arweave_anchor(
&repo_slug, &owner_did_for_arweave, ref_name, "0".repeat(64).as_str(),
new_sha, cid.as_deref(), &tx_id, &arweave_url, &node_did_str,
).await;
let _ = db_clone
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &repo_slug,
owner_did: &owner_did_for_arweave,
ref_name,
old_sha: "0".repeat(64).as_str(),
new_sha,
cid: cid.as_deref(),
irys_tx_id: &tx_id,
arweave_url: &arweave_url,
node_did: &node_did_str,
})
.await;
}
Ok(_) => {}
Err(e) => tracing::warn!(repo=%repo_slug, err=%e, "Arweave anchor failed"),
@@ -579,13 +738,18 @@ pub async fn list_refs(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let _record = state.db.get_repo(&owner, &repo).await?
let _record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let repo_slug = format!("{owner}/{repo}");
let refs = state.db.list_branch_cids(&repo_slug).await?;
Ok(Json(serde_json::json!({ "refs": refs, "count": refs.len() })))
Ok(Json(
serde_json::json!({ "refs": refs, "count": refs.len() }),
))
}
/// GET /api/v1/repos/federated
@@ -596,17 +760,17 @@ pub async fn list_refs(
pub async fn list_federated_repos(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>> {
// Our own repos
let local_repos = state.db.list_all_repos().await?;
let local_node_url = state.config.public_url
let local_repos = state.db.list_all_repos_with_stars().await?;
let local_node_url = state
.config
.public_url
.clone()
.unwrap_or_else(|| "http://127.0.0.1:7545".to_string());
let local_node_did = state.node_did.to_string();
let mut all_repos: Vec<serde_json::Value> = Vec::with_capacity(local_repos.len());
for r in &local_repos {
let count = state.db.count_stars(&r.id).await.unwrap_or(0);
let mut v = serde_json::to_value(to_response(r, &state, count)).unwrap_or_default();
for (r, count) in &local_repos {
let mut v = serde_json::to_value(to_response(r, &state, *count)).unwrap_or_default();
v["node_url"] = serde_json::Value::String(local_node_url.clone());
v["node_did"] = serde_json::Value::String(local_node_did.clone());
v["local"] = serde_json::Value::Bool(true);
@@ -617,7 +781,8 @@ pub async fn list_federated_repos(
let peers = state.db.list_peers().await.unwrap_or_default();
let client = &state.http_client;
let fetch_tasks: Vec<_> = peers.into_iter()
let fetch_tasks: Vec<_> = peers
.into_iter()
.filter(|p| p.last_ping_ok && !p.http_url.is_empty())
.map(|peer| {
let client = Arc::clone(client);
@@ -628,16 +793,20 @@ pub async fn list_federated_repos(
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.get(&url).send(),
).await;
)
.await;
match result {
Ok(Ok(resp)) if resp.status().is_success() => {
if let Ok(repos) = resp.json::<Vec<serde_json::Value>>().await {
let enriched: Vec<serde_json::Value> = repos.into_iter().map(|mut r| {
r["node_url"] = serde_json::Value::String(peer_url.clone());
r["node_did"] = serde_json::Value::String(peer_did.clone());
r["local"] = serde_json::Value::Bool(false);
r
}).collect();
let enriched: Vec<serde_json::Value> = repos
.into_iter()
.map(|mut r| {
r["node_url"] = serde_json::Value::String(peer_url.clone());
r["node_did"] = serde_json::Value::String(peer_did.clone());
r["local"] = serde_json::Value::Bool(false);
r
})
.collect();
return enriched;
}
}
@@ -676,44 +845,65 @@ pub async fn fork_repo(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<ForkRepoRequest>,
) -> Result<(StatusCode, Json<RepoResponse>)> {
let source = state.db.get_repo(&owner, &name).await?
let source = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let fork_name = req.name.unwrap_or_else(|| source.name.clone());
let forker_did = auth.0;
// Validate fork name
if !fork_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
if !fork_name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
}
// Check no name conflict under the forker's ownership
let forker_short = forker_did.split(':').last().unwrap_or(&forker_did);
let forker_short = forker_did.split(':').next_back().unwrap_or(&forker_did);
if state.db.get_repo(forker_short, &fork_name).await?.is_some() {
return Err(AppError::BadRequest(format!("you already have a repo named {fork_name}")));
return Err(AppError::BadRequest(format!(
"you already have a repo named {fork_name}"
)));
}
// Ensure source repo is on local disk (downloads from Tigris on cache miss)
let source_path = state.repo_store.acquire(&source.owner_did, &source.name).await
let source_path = state
.repo_store
.acquire(&source.owner_did, &source.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = store::repo_disk_path(&state.config.repos_dir, &forker_did, &fork_name);
// Clone the source repo as a mirror
let output = std::process::Command::new("git")
.args(["clone", "--mirror", source_path.to_str().unwrap_or(""), disk_path.to_str().unwrap_or("")])
.args([
"clone",
"--mirror",
source_path.to_str().unwrap_or(""),
disk_path.to_str().unwrap_or(""),
])
.output()
.map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::Git(format!("git clone --mirror failed: {stderr}")));
return Err(AppError::Git(format!(
"git clone --mirror failed: {stderr}"
)));
}
// Upload fork to Tigris
state.repo_store.release_after_write(&forker_did, &fork_name).await;
state
.repo_store
.release_after_write(&forker_did, &fork_name)
.await;
let now = Utc::now();
let record = crate::db::RepoRecord {
@@ -779,7 +969,11 @@ fn parse_ref_updates(body: &[u8]) -> Vec<RefUpdate> {
};
// Strip capabilities (after NUL) and trailing newline
let line = line.split('\0').next().unwrap_or(line).trim_end_matches('\n');
let line = line
.split('\0')
.next()
.unwrap_or(line)
.trim_end_matches('\n');
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() == 3 && parts[0].len() == 40 && parts[1].len() == 40 {
@@ -821,12 +1015,15 @@ fn extract_did_from_auth(headers: &HeaderMap) -> Option<String> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn to_response(record: &crate::db::RepoRecord, state: &AppState, star_count: i64) -> RepoResponse {
let owner_short = record.owner_did
let owner_short = record
.owner_did
.split(':')
.last()
.next_back()
.unwrap_or(&record.owner_did);
let base_url = state.config.public_url
let base_url = state
.config
.public_url
.as_deref()
.unwrap_or("http://127.0.0.1:7545")
.trim_end_matches('/');
+25 -9
View File
@@ -19,22 +19,32 @@ pub async fn star_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
let inserted = state.db.star_repo(&record.id, caller).await?;
let count = state.db.count_stars(&record.id).await?;
let status = if inserted { StatusCode::CREATED } else { StatusCode::OK };
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
tracing::info!(repo = %repo, caller = %caller, "repo starred");
Ok((status, Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
}))))
Ok((
status,
Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
})),
))
}
/// DELETE /api/v1/repos/:owner/:repo/star
@@ -44,7 +54,10 @@ pub async fn unstar_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
@@ -66,7 +79,10 @@ pub async fn get_star_status(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &repo).await?
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let count = state.db.count_stars(&record.id).await?;
+31 -10
View File
@@ -33,12 +33,17 @@ pub async fn create_webhook(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<(StatusCode, Json<Webhook>)> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// Validate URL is http/https
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(AppError::BadRequest("webhook URL must be http:// or https://".into()));
return Err(AppError::BadRequest(
"webhook URL must be http:// or https://".into(),
));
}
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
@@ -64,17 +69,27 @@ pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
let redacted: Vec<_> = hooks.into_iter().map(|mut h| {
if h.secret.is_some() { h.secret = Some("***".into()); }
h
}).collect();
let redacted: Vec<_> = hooks
.into_iter()
.map(|mut h| {
if h.secret.is_some() {
h.secret = Some("***".into());
}
h
})
.collect();
Ok(Json(serde_json::json!({ "webhooks": redacted, "count": redacted.len() })))
Ok(Json(
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
))
}
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
@@ -82,11 +97,17 @@ pub async fn delete_webhook(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state.db.get_repo(&owner, &name).await?
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// Verify the webhook belongs to this repo
let hook = state.db.get_webhook(&id).await?
let hook = state
.db
.get_webhook(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id} not found")))?;
if hook.repo_id != record.id {
+11 -6
View File
@@ -20,7 +20,6 @@
use anyhow::Result;
use serde_json::json;
/// Data describing a ref-update event to be anchored.
#[derive(Debug, Clone)]
pub struct RefAnchor {
@@ -112,9 +111,9 @@ pub fn arweave_url(tx_id: &str) -> String {
/// Build the Irys tag header value for Arweave indexing.
/// Format: comma-separated "name:value" pairs.
fn build_tags_header(anchor: &RefAnchor) -> String {
vec![
format!("App-Name:gitlawb"),
format!("Schema:gitlawb/ref-update/v1"),
[
"App-Name:gitlawb".to_string(),
"Schema:gitlawb/ref-update/v1".to_string(),
format!("Repo:{}", sanitize_tag(&anchor.repo)),
format!("Ref:{}", sanitize_tag(&anchor.ref_name)),
format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]),
@@ -178,14 +177,20 @@ mod tests {
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
assert!(result.is_ok(), "anchor should succeed: {result:?}");
assert_eq!(result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
assert_eq!(
result.unwrap(),
"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
_mock.assert_async().await;
}
#[test]
fn test_arweave_url() {
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
assert_eq!(url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
assert_eq!(
url,
"https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
}
#[test]
+32 -25
View File
@@ -34,16 +34,17 @@ use gitlawb_core::identity::verify;
pub async fn require_signature(request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream
let (parts, body) = request.into_parts();
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => {
return (
let body_bytes =
match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "unreadable_body", "message": "could not read request body" })),
Json(
json!({ "error": "unreadable_body", "message": "could not read request body" }),
),
)
.into_response()
}
};
.into_response(),
};
let sig_input = parts
.headers
@@ -61,8 +62,9 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
(Some(i), Some(s)) => (i, s),
_ => {
return human_detected(
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures"
).into_response();
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures",
)
.into_response();
}
};
@@ -156,22 +158,21 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
request_values.insert("content-digest".to_string(), content_digest);
// The @signature-params value is the part of Signature-Input after "sig1="
let sig_params_value = sig_input
.strip_prefix("sig1=")
.unwrap_or(&sig_input);
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
let signing_string =
match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
// Verify Ed25519 signature
let sig_array: [u8; 64] = match sig.signature_bytes.as_slice().try_into() {
@@ -200,7 +201,11 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
}
// Verify Content-Digest matches the actual request body
if let Some(claimed) = parts.headers.get("content-digest").and_then(|v| v.to_str().ok()) {
if let Some(claimed) = parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
{
let actual = compute_content_digest(&body_bytes);
if claimed != actual {
return (
@@ -217,7 +222,9 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
tracing::info!(did = %sig.key_id, "✓ authenticated request");
let mut request = Request::from_parts(parts, Body::from(body_bytes));
request.extensions_mut().insert(AuthenticatedDid(sig.key_id.to_string()));
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
next.run(request).await
}
+316
View File
@@ -0,0 +1,316 @@
//! Embedded seed list of public Gitlawb network nodes.
//!
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
//! merges its contents into the runtime config so a fresh `docker compose up`
//! joins the network without any manual peer configuration.
//!
//! Operators can opt out by setting `GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true` in
//! their environment — useful for isolated dev networks or testing.
//!
//! Add a node to the canonical list via PR to `bootstrap-peers.json`.
use std::str::FromStr;
use libp2p::Multiaddr;
use serde::Deserialize;
use tracing::{info, warn};
use crate::config::Config;
const EMBEDDED_PEERS_JSON: &str = include_str!("../../../bootstrap-peers.json");
const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
struct BootstrapList {
version: u32,
#[serde(default)]
peers: Vec<BootstrapPeer>,
}
#[derive(Debug, Deserialize)]
struct BootstrapPeer {
name: String,
#[allow(dead_code)]
operator: Option<String>,
#[allow(dead_code)]
did: Option<String>,
http_url: Option<String>,
p2p_multiaddr: Option<String>,
#[allow(dead_code)]
added: Option<String>,
}
/// Counts of newly-added entries returned by `merge_into_vecs`.
#[derive(Debug, Default, PartialEq, Eq)]
struct MergeCounts {
http: usize,
p2p: usize,
}
/// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
fn seeds_disabled() -> bool {
std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
.ok()
.filter(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
.is_some()
}
/// Parse the seed list from a JSON string, rejecting unsupported versions.
fn parse_seed_list(json: &str) -> Result<BootstrapList, String> {
let list: BootstrapList = serde_json::from_str(json).map_err(|e| e.to_string())?;
if list.version != SUPPORTED_VERSION {
return Err(format!(
"unsupported bootstrap-peers.json version: {} (expected {})",
list.version, SUPPORTED_VERSION
));
}
Ok(list)
}
/// Pure merge: appends entries from `list` to the two vectors, deduping.
/// Returns counts of entries actually added (i.e. not already present).
fn merge_into_vecs(
list: BootstrapList,
http_peers: &mut Vec<String>,
p2p_bootstrap: &mut Vec<String>,
) -> MergeCounts {
let mut counts = MergeCounts::default();
for peer in list.peers {
if let Some(url) = peer
.http_url
.as_ref()
.filter(|u| !u.is_empty() && !http_peers.contains(u))
{
http_peers.push(url.clone());
counts.http += 1;
}
if let Some(addr_str) = peer.p2p_multiaddr.as_ref().filter(|s| !s.is_empty()) {
match Multiaddr::from_str(addr_str) {
Ok(_) => {
if !p2p_bootstrap.contains(addr_str) {
p2p_bootstrap.push(addr_str.clone());
counts.p2p += 1;
}
}
Err(e) => warn!(
name = %peer.name,
addr = %addr_str,
err = %e,
"invalid p2p_multiaddr in bootstrap-peers.json — skipping"
),
}
}
}
counts
}
/// Merge the embedded seed list into the runtime config.
///
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
/// - Dedupes against entries already present (env / CLI takes precedence)
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
pub fn merge_seeds(config: &mut Config) {
if seeds_disabled() {
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
return;
}
let list = match parse_seed_list(EMBEDDED_PEERS_JSON) {
Ok(l) => l,
Err(e) => {
warn!(err = %e, "failed to load embedded bootstrap-peers.json — skipping");
return;
}
};
let counts = merge_into_vecs(list, &mut config.bootstrap_peers, &mut config.p2p_bootstrap);
if counts.http > 0 || counts.p2p > 0 {
info!(
http_peers = counts.http,
p2p_peers = counts.p2p,
"merged bootstrap seed list into config"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_v1_list() {
let json = r#"{
"version": 1,
"updated": "2026-04-29",
"peers": [
{
"name": "alpha",
"operator": "Alice",
"did": "did:key:z6MkAlice",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
"added": "2026-04-29"
}
]
}"#;
let list = parse_seed_list(json).expect("should parse");
assert_eq!(list.version, 1);
assert_eq!(list.peers.len(), 1);
assert_eq!(list.peers[0].name, "alpha");
}
#[test]
fn parse_rejects_unknown_version() {
let json = r#"{ "version": 99, "peers": [] }"#;
let err = parse_seed_list(json).expect_err("should reject");
assert!(err.contains("unsupported"));
}
#[test]
fn parse_rejects_malformed_json() {
let err = parse_seed_list("{ not json").expect_err("should reject");
assert!(!err.is_empty());
}
#[test]
fn parse_accepts_empty_peers_array() {
let json = r#"{ "version": 1, "peers": [] }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn parse_treats_missing_peers_as_empty() {
let json = r#"{ "version": 1 }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn merge_appends_new_http_and_p2p() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 1 });
assert_eq!(http, vec!["https://alpha.example.com"]);
assert_eq!(p2p, vec!["/ip4/1.2.3.4/tcp/7546"]);
}
#[test]
fn merge_dedupes_existing_entries() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "alpha", "http_url": "https://alpha.example.com" }
]
}"#,
)
.unwrap();
let mut http = vec!["https://alpha.example.com".to_string()];
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 0, "should not double-add");
assert_eq!(http.len(), 1);
}
#[test]
fn merge_skips_invalid_p2p_multiaddr() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "bad",
"http_url": "https://bad.example.com",
"p2p_multiaddr": "this is not a multiaddr"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 1, "http still added");
assert_eq!(counts.p2p, 0, "invalid p2p skipped");
assert!(p2p.is_empty());
}
#[test]
fn merge_skips_empty_strings() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "blank", "http_url": "", "p2p_multiaddr": "" }
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts::default());
}
#[test]
fn merge_handles_null_optional_fields() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"operator": null,
"did": null,
"http_url": "https://alpha.example.com",
"p2p_multiaddr": null,
"added": null
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 0 });
}
#[test]
fn embedded_seed_list_parses_successfully() {
// Regression: the canonical bootstrap-peers.json shipped in the repo
// must always be valid, since it's compiled into the binary.
parse_seed_list(EMBEDDED_PEERS_JSON)
.expect("embedded bootstrap-peers.json must always parse");
}
}
+5 -5
View File
@@ -41,11 +41,11 @@ pub async fn issue_ref_certificate(
let signature = state.node_keypair.sign_b64(&payload_bytes);
let cert = RefCertificate {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
pusher_did: pusher_did.to_string(),
node_did,
signature,
+16 -4
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
@@ -9,7 +9,11 @@ pub struct Config {
pub repos_dir: PathBuf,
/// PostgreSQL connection URL (Supabase or any Postgres instance)
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://localhost/gitlawb")]
#[arg(
long,
env = "DATABASE_URL",
default_value = "postgresql://localhost/gitlawb"
)]
pub database_url: String,
/// Host to bind to
@@ -45,7 +49,11 @@ pub struct Config {
pub pinata_jwt: String,
/// Pinata v3 upload URL
#[arg(long, env = "GITLAWB_PINATA_UPLOAD_URL", default_value = "https://uploads.pinata.cloud/v3/files")]
#[arg(
long,
env = "GITLAWB_PINATA_UPLOAD_URL",
default_value = "https://uploads.pinata.cloud/v3/files"
)]
pub pinata_upload_url: String,
/// libp2p TCP port (0 = disabled)
@@ -75,7 +83,11 @@ pub struct Config {
pub contract_name_registry: String,
/// Base L2 RPC URL
#[arg(long, env = "GITLAWB_CHAIN_RPC_URL", default_value = "https://sepolia.base.org")]
#[arg(
long,
env = "GITLAWB_CHAIN_RPC_URL",
default_value = "https://sepolia.base.org"
)]
pub chain_rpc_url: String,
/// Base L2 node staking contract address (GitlawbNodeStaking). When set
+421 -179
View File
@@ -100,6 +100,13 @@ pub struct PeerRecord {
pub announced_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoReplica {
pub replica_did: String,
pub replica_url: String,
pub registered_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinnedCidRecord {
pub sha256_hex: String,
@@ -208,6 +215,8 @@ impl Db {
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS machine_id TEXT",
"CREATE INDEX IF NOT EXISTS idx_repos_owner ON repos(owner_did)",
"CREATE INDEX IF NOT EXISTS idx_repos_name ON repos(name)",
"CREATE INDEX IF NOT EXISTS idx_repos_owner_short_name ON repos ((split_part(owner_did, ':', -1)), name)",
"CREATE INDEX IF NOT EXISTS idx_repos_updated_at ON repos (updated_at DESC)",
r#"CREATE TABLE IF NOT EXISTS agents (
did TEXT NOT NULL PRIMARY KEY,
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0.0,
@@ -379,6 +388,21 @@ impl Db {
)"#,
"CREATE INDEX IF NOT EXISTS idx_repo_stars_repo ON repo_stars(repo_id)",
"CREATE INDEX IF NOT EXISTS idx_repo_stars_agent ON repo_stars(agent_did)",
// ── Repo replicas (network resilience) ──────────────────────────
// Tracks which nodes are hosting a replica of a repo. Populated
// when a replica node calls PUT /api/v1/repos/{owner}/{repo}/replicas
// on the origin. Public via GET on the same path — anyone can see
// how many nodes are mirroring a given repo.
r#"CREATE TABLE IF NOT EXISTS repo_replicas (
id TEXT NOT NULL PRIMARY KEY,
repo_id TEXT NOT NULL,
replica_did TEXT NOT NULL,
replica_url TEXT NOT NULL,
registered_at TEXT NOT NULL,
UNIQUE(repo_id, replica_did)
)"#,
"CREATE INDEX IF NOT EXISTS idx_repo_replicas_repo ON repo_replicas(repo_id)",
"CREATE INDEX IF NOT EXISTS idx_repo_replicas_did ON repo_replicas(replica_did)",
// ── PR comments ─────────────────────────────────────────────────
r#"CREATE TABLE IF NOT EXISTS pr_comments (
id TEXT NOT NULL PRIMARY KEY,
@@ -465,7 +489,13 @@ impl Db {
/// Register a mirrored repo from a peer in the local DB so git smart HTTP can serve it.
/// Uses INSERT OR IGNORE (SQLite) / ON CONFLICT DO NOTHING (Postgres) so it's idempotent.
pub async fn upsert_mirror_repo(&self, owner_short: &str, name: &str, disk_path: &str, machine_id: Option<&str>) -> Result<()> {
pub async fn upsert_mirror_repo(
&self,
owner_short: &str,
name: &str,
disk_path: &str,
machine_id: Option<&str>,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
let id = format!("{owner_short}/{name}");
sqlx::query(
@@ -530,6 +560,93 @@ impl Db {
Ok(rows.into_iter().map(row_to_repo).collect())
}
pub async fn list_all_repos_with_stars(&self) -> Result<Vec<(RepoRecord, i64)>> {
let rows = sqlx::query(
"SELECT r.id, r.name, r.owner_did, r.description, r.is_public, r.default_branch,
r.created_at, r.updated_at, r.disk_path, r.forked_from, r.machine_id,
COALESCE(s.cnt, 0) AS star_count
FROM repos r
LEFT JOIN (SELECT repo_id, COUNT(*) AS cnt FROM repo_stars GROUP BY repo_id) s
ON s.repo_id = r.id
ORDER BY r.updated_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
let stars: i64 = r.get("star_count");
(row_to_repo(r), stars)
})
.collect())
}
pub async fn list_all_repos_paged(
&self,
owner_filter: Option<&str>,
limit: i64,
offset: i64,
) -> Result<(Vec<(RepoRecord, i64)>, i64)> {
let rows = sqlx::query(
"WITH deduped AS (
SELECT DISTINCT ON (split_part(owner_did, ':', -1), name)
id, name, owner_did, description, is_public, default_branch,
created_at, updated_at, disk_path, forked_from, machine_id
FROM repos
WHERE ($1::text IS NULL OR owner_did = $1 OR owner_did LIKE '%:' || $1)
ORDER BY split_part(owner_did, ':', -1), name,
CASE WHEN description = 'mirrored from peer' THEN 1 ELSE 0 END,
created_at ASC
)
SELECT
d.id, d.name, d.owner_did, d.description, d.is_public,
d.default_branch, d.created_at, d.updated_at, d.disk_path,
d.forked_from, d.machine_id,
COALESCE(s.cnt, 0) AS star_count,
COUNT(*) OVER () AS total_count
FROM deduped d
LEFT JOIN (
SELECT repo_id, COUNT(*) AS cnt FROM repo_stars GROUP BY repo_id
) s ON s.repo_id = d.id
ORDER BY d.updated_at DESC
LIMIT $2 OFFSET $3",
)
.bind(owner_filter)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
let total = rows
.first()
.map(|r| r.get::<i64, _>("total_count"))
.unwrap_or(0);
let out: Vec<(RepoRecord, i64)> = rows
.into_iter()
.map(|r| {
let stars: i64 = r.get("star_count");
(row_to_repo(r), stars)
})
.collect();
let total = if out.is_empty() {
let row = sqlx::query(
"SELECT COUNT(DISTINCT (split_part(owner_did, ':', -1), name)) AS cnt
FROM repos
WHERE ($1::text IS NULL OR owner_did = $1 OR owner_did LIKE '%:' || $1)",
)
.bind(owner_filter)
.fetch_one(&self.pool)
.await?;
row.get::<i64, _>("cnt")
} else {
total
};
Ok((out, total))
}
pub async fn touch_repo(&self, id: &str) -> Result<()> {
sqlx::query("UPDATE repos SET updated_at = $1 WHERE id = $2")
.bind(Utc::now().to_rfc3339())
@@ -560,12 +677,10 @@ impl Db {
}
pub async fn get_trust_score(&self, agent_did: &str) -> Result<f64> {
let row = sqlx::query(
"SELECT trust_score FROM agents WHERE did = $1",
)
.bind(agent_did)
.fetch_optional(&self.pool)
.await?;
let row = sqlx::query("SELECT trust_score FROM agents WHERE did = $1")
.bind(agent_did)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get::<f64, _>("trust_score")).unwrap_or(0.0))
}
@@ -608,12 +723,10 @@ impl Db {
}
pub async fn get_push_count(&self, agent_did: &str) -> Result<i64> {
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM push_events WHERE agent_did = $1",
)
.bind(agent_did)
.fetch_one(&self.pool)
.await?;
let row = sqlx::query("SELECT COUNT(*) as cnt FROM push_events WHERE agent_did = $1")
.bind(agent_did)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt"))
}
@@ -631,13 +744,17 @@ impl Db {
.fetch_all(&self.pool)
.await?;
let mut agents: Vec<AgentRow> = rows.iter().map(|r| AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")).unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
}).collect();
let mut agents: Vec<AgentRow> = rows
.iter()
.map(|r| AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities"))
.unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
})
.collect();
if let Some(cap) = capability {
agents.retain(|a| a.capabilities.iter().any(|c| c == cap));
@@ -657,7 +774,8 @@ impl Db {
Ok(row.map(|r| AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")).unwrap_or_default(),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities"))
.unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
}))
@@ -718,14 +836,17 @@ impl Db {
.bind(repo)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| BranchCid {
repo: r.get("repo"),
ref_name: r.get("ref_name"),
sha: r.get("sha"),
cid: r.get("cid"),
node_did: r.get("node_did"),
updated_at: r.get("updated_at"),
}).collect())
Ok(rows
.into_iter()
.map(|r| BranchCid {
repo: r.get("repo"),
ref_name: r.get("ref_name"),
sha: r.get("sha"),
cid: r.get("cid"),
node_did: r.get("node_did"),
updated_at: r.get("updated_at"),
})
.collect())
}
}
@@ -778,37 +899,36 @@ impl Db {
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| SyncQueueItem {
id: r.get("id"),
repo: r.get("repo"),
node_did: r.get("node_did"),
ref_name: r.get("ref_name"),
new_sha: r.get("new_sha"),
cid: r.get("cid"),
status: r.get("status"),
enqueued_at: r.get("enqueued_at"),
}).collect())
Ok(rows
.into_iter()
.map(|r| SyncQueueItem {
id: r.get("id"),
repo: r.get("repo"),
node_did: r.get("node_did"),
ref_name: r.get("ref_name"),
new_sha: r.get("new_sha"),
cid: r.get("cid"),
status: r.get("status"),
enqueued_at: r.get("enqueued_at"),
})
.collect())
}
pub async fn mark_sync_done(&self, id: &str) -> Result<()> {
sqlx::query(
"UPDATE sync_queue SET status = 'done', processed_at = $1 WHERE id = $2",
)
.bind(Utc::now().to_rfc3339())
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE sync_queue SET status = 'done', processed_at = $1 WHERE id = $2")
.bind(Utc::now().to_rfc3339())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn mark_sync_failed(&self, id: &str) -> Result<()> {
sqlx::query(
"UPDATE sync_queue SET status = 'failed', processed_at = $1 WHERE id = $2",
)
.bind(Utc::now().to_rfc3339())
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE sync_queue SET status = 'failed', processed_at = $1 WHERE id = $2")
.bind(Utc::now().to_rfc3339())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
@@ -890,13 +1010,11 @@ impl Db {
pub async fn close_pr(&self, pr_id: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE pull_requests SET status='closed', updated_at=$1 WHERE id=$2",
)
.bind(&now)
.bind(pr_id)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE pull_requests SET status='closed', updated_at=$1 WHERE id=$2")
.bind(&now)
.bind(pr_id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -1015,13 +1133,15 @@ impl Db {
}
pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<String>> {
let rows = sqlx::query(
"SELECT label FROM repo_labels WHERE repo_id = $1 ORDER BY label ASC",
)
.bind(repo_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.try_get::<String, _>("label").unwrap_or_default()).collect())
let rows =
sqlx::query("SELECT label FROM repo_labels WHERE repo_id = $1 ORDER BY label ASC")
.bind(repo_id)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| r.try_get::<String, _>("label").unwrap_or_default())
.collect())
}
pub async fn list_pr_reviews(&self, pr_id: &str) -> Result<Vec<PrReview>> {
@@ -1098,7 +1218,11 @@ impl Db {
Ok(result.rows_affected() > 0)
}
pub async fn list_webhooks_for_event(&self, repo_id: &str, event: &str) -> Result<Vec<Webhook>> {
pub async fn list_webhooks_for_event(
&self,
repo_id: &str,
event: &str,
) -> Result<Vec<Webhook>> {
let all = self.list_webhooks(repo_id).await?;
Ok(all
.into_iter()
@@ -1172,14 +1296,12 @@ impl Db {
}
pub async fn mark_peer_ping(&self, did: &str, ok: bool) -> Result<()> {
sqlx::query(
"UPDATE peers SET last_seen = $1, last_ping_ok = $2 WHERE did = $3",
)
.bind(Utc::now().to_rfc3339())
.bind(ok)
.bind(did)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE peers SET last_seen = $1, last_ping_ok = $2 WHERE did = $3")
.bind(Utc::now().to_rfc3339())
.bind(ok)
.bind(did)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -1201,18 +1323,27 @@ impl Db {
})
.collect())
}
pub async fn prune_self_peers(&self, public_url: &str) -> Result<u64> {
let trimmed = public_url.trim_end_matches('/');
let with_slash = format!("{trimmed}/");
let result = sqlx::query("DELETE FROM peers WHERE http_url = $1 OR http_url = $2")
.bind(trimmed)
.bind(&with_slash)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
// ── Pinned CIDs ───────────────────────────────────────────────────────────────
impl Db {
pub async fn is_pinned(&self, sha256_hex: &str) -> Result<bool> {
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM pinned_cids WHERE sha256_hex = $1",
)
.bind(sha256_hex)
.fetch_one(&self.pool)
.await?;
let row = sqlx::query("SELECT COUNT(*) as cnt FROM pinned_cids WHERE sha256_hex = $1")
.bind(sha256_hex)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") > 0)
}
@@ -1316,7 +1447,11 @@ impl Db {
Ok(rows.into_iter().map(row_to_ref_update).collect())
}
pub async fn list_repo_ref_updates(&self, repo: &str, limit: i64) -> Result<Vec<ReceivedRefUpdate>> {
pub async fn list_repo_ref_updates(
&self,
repo: &str,
limit: i64,
) -> Result<Vec<ReceivedRefUpdate>> {
let rows = sqlx::query(
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
cert_id, received_at, from_peer
@@ -1330,7 +1465,11 @@ impl Db {
}
/// Filtered ref updates — optionally scoped to a specific repo.
pub async fn list_ref_updates_filtered(&self, repo: Option<&str>, limit: i64) -> Result<Vec<ReceivedRefUpdate>> {
pub async fn list_ref_updates_filtered(
&self,
repo: Option<&str>,
limit: i64,
) -> Result<Vec<ReceivedRefUpdate>> {
let rows = if let Some(r) = repo {
sqlx::query(
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
@@ -1392,7 +1531,12 @@ impl Db {
Ok(row.map(row_to_task))
}
pub async fn list_tasks(&self, status: Option<&str>, assignee_did: Option<&str>, limit: i64) -> Result<Vec<AgentTask>> {
pub async fn list_tasks(
&self,
status: Option<&str>,
assignee_did: Option<&str>,
limit: i64,
) -> Result<Vec<AgentTask>> {
let rows = match (status, assignee_did) {
(Some(s), Some(a)) => sqlx::query(
"SELECT id, repo_id, kind, status, delegator_did, assignee_did, capability, ucan_token, payload, result, created_at, updated_at, deadline
@@ -1446,7 +1590,12 @@ impl Db {
.ok_or_else(|| anyhow::anyhow!("task not claimable: not found or already claimed"))
}
pub async fn finish_task(&self, id: &str, new_status: &str, result: Option<&str>) -> Result<AgentTask> {
pub async fn finish_task(
&self,
id: &str,
new_status: &str,
result: Option<&str>,
) -> Result<AgentTask> {
let now = Utc::now().to_rfc3339();
let row = sqlx::query(
"UPDATE agent_tasks SET status=$2, result=$3, updated_at=$4
@@ -1481,19 +1630,21 @@ pub struct ArweaveAnchor {
pub anchored_at: String,
}
/// Input parameters for recording an Arweave anchor.
pub struct RecordAnchorInput<'a> {
pub repo: &'a str,
pub owner_did: &'a str,
pub ref_name: &'a str,
pub old_sha: &'a str,
pub new_sha: &'a str,
pub cid: Option<&'a str>,
pub irys_tx_id: &'a str,
pub arweave_url: &'a str,
pub node_did: &'a str,
}
impl Db {
pub async fn record_arweave_anchor(
&self,
repo: &str,
owner_did: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
cid: Option<&str>,
irys_tx_id: &str,
arweave_url: &str,
node_did: &str,
) -> Result<()> {
pub async fn record_arweave_anchor(&self, input: &RecordAnchorInput<'_>) -> Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
@@ -1501,22 +1652,26 @@ impl Db {
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
)
.bind(&id)
.bind(repo)
.bind(owner_did)
.bind(ref_name)
.bind(old_sha)
.bind(new_sha)
.bind(cid)
.bind(irys_tx_id)
.bind(arweave_url)
.bind(node_did)
.bind(input.repo)
.bind(input.owner_did)
.bind(input.ref_name)
.bind(input.old_sha)
.bind(input.new_sha)
.bind(input.cid)
.bind(input.irys_tx_id)
.bind(input.arweave_url)
.bind(input.node_did)
.bind(&now)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_arweave_anchors(&self, repo: Option<&str>, limit: i64) -> Result<Vec<ArweaveAnchor>> {
pub async fn list_arweave_anchors(
&self,
repo: Option<&str>,
limit: i64,
) -> Result<Vec<ArweaveAnchor>> {
let rows = if let Some(repo) = repo {
sqlx::query(
"SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at
@@ -1536,19 +1691,22 @@ impl Db {
.await?
};
Ok(rows.into_iter().map(|r| ArweaveAnchor {
id: r.get("id"),
repo: r.get("repo"),
owner_did: r.get("owner_did"),
ref_name: r.get("ref_name"),
old_sha: r.get("old_sha"),
new_sha: r.get("new_sha"),
cid: r.get("cid"),
irys_tx_id: r.get("irys_tx_id"),
arweave_url: r.get("arweave_url"),
node_did: r.get("node_did"),
anchored_at: r.get("anchored_at"),
}).collect())
Ok(rows
.into_iter()
.map(|r| ArweaveAnchor {
id: r.get("id"),
repo: r.get("repo"),
owner_did: r.get("owner_did"),
ref_name: r.get("ref_name"),
old_sha: r.get("old_sha"),
new_sha: r.get("new_sha"),
cid: r.get("cid"),
irys_tx_id: r.get("irys_tx_id"),
arweave_url: r.get("arweave_url"),
node_did: r.get("node_did"),
anchored_at: r.get("anchored_at"),
})
.collect())
}
}
@@ -1564,8 +1722,12 @@ fn row_to_repo(r: sqlx::postgres::PgRow) -> RepoRecord {
description: r.get("description"),
is_public: r.get::<bool, _>("is_public"),
default_branch: r.get("default_branch"),
created_at: created_str.parse::<DateTime<Utc>>().unwrap_or_else(|_| Utc::now()),
updated_at: updated_str.parse::<DateTime<Utc>>().unwrap_or_else(|_| Utc::now()),
created_at: created_str
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now()),
updated_at: updated_str
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now()),
disk_path: r.get("disk_path"),
forked_from: r.try_get("forked_from").unwrap_or(None),
machine_id: r.try_get("machine_id").unwrap_or(None),
@@ -1657,7 +1819,12 @@ fn row_to_task(r: sqlx::postgres::PgRow) -> AgentTask {
// ── Protected Branches ────────────────────────────────────────────────────────
impl Db {
pub async fn protect_branch(&self, repo_id: &str, branch: &str, created_by: &str) -> Result<()> {
pub async fn protect_branch(
&self,
repo_id: &str,
branch: &str,
created_by: &str,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
let id = format!("{repo_id}:{branch}");
sqlx::query(
@@ -1676,34 +1843,33 @@ impl Db {
}
pub async fn unprotect_branch(&self, repo_id: &str, branch: &str) -> Result<()> {
sqlx::query(
"DELETE FROM protected_branches WHERE repo_id = $1 AND branch = $2",
)
.bind(repo_id)
.bind(branch)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM protected_branches WHERE repo_id = $1 AND branch = $2")
.bind(repo_id)
.bind(branch)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_protected_branches(&self, repo_id: &str) -> Result<Vec<String>> {
let rows = sqlx::query(
"SELECT branch FROM protected_branches WHERE repo_id = $1 ORDER BY branch",
)
.bind(repo_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| r.get::<String, _>("branch")).collect())
let rows =
sqlx::query("SELECT branch FROM protected_branches WHERE repo_id = $1 ORDER BY branch")
.bind(repo_id)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| r.get::<String, _>("branch"))
.collect())
}
pub async fn is_branch_protected(&self, repo_id: &str, branch: &str) -> Result<bool> {
let row = sqlx::query(
"SELECT 1 FROM protected_branches WHERE repo_id = $1 AND branch = $2",
)
.bind(repo_id)
.bind(branch)
.fetch_optional(&self.pool)
.await?;
let row =
sqlx::query("SELECT 1 FROM protected_branches WHERE repo_id = $1 AND branch = $2")
.bind(repo_id)
.bind(branch)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
}
@@ -1731,37 +1897,99 @@ impl Db {
/// Unstar a repo. Idempotent — no error if not starred.
pub async fn unstar_repo(&self, repo_id: &str, agent_did: &str) -> Result<()> {
sqlx::query(
"DELETE FROM repo_stars WHERE repo_id = $1 AND agent_did = $2",
)
.bind(repo_id)
.bind(agent_did)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM repo_stars WHERE repo_id = $1 AND agent_did = $2")
.bind(repo_id)
.bind(agent_did)
.execute(&self.pool)
.await?;
Ok(())
}
/// Count total stars for a repo.
pub async fn count_stars(&self, repo_id: &str) -> Result<i64> {
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM repo_stars WHERE repo_id = $1",
let row = sqlx::query("SELECT COUNT(*) as cnt FROM repo_stars WHERE repo_id = $1")
.bind(repo_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt"))
}
// ── Repo replicas ──────────────────────────────────────────────────
/// Register a replica for a repo. Returns true if inserted, false if the
/// replica was already registered (URL updated either way).
pub async fn register_replica(
&self,
repo_id: &str,
replica_did: &str,
replica_url: &str,
) -> Result<bool> {
let now = Utc::now().to_rfc3339();
let id = format!("{repo_id}:{replica_did}");
let result = sqlx::query(
"INSERT INTO repo_replicas (id, repo_id, replica_did, replica_url, registered_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (repo_id, replica_did) DO UPDATE
SET replica_url = EXCLUDED.replica_url",
)
.bind(&id)
.bind(repo_id)
.bind(replica_did)
.bind(replica_url)
.bind(&now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Unregister a replica. Idempotent.
pub async fn unregister_replica(&self, repo_id: &str, replica_did: &str) -> Result<()> {
sqlx::query("DELETE FROM repo_replicas WHERE repo_id = $1 AND replica_did = $2")
.bind(repo_id)
.bind(replica_did)
.execute(&self.pool)
.await?;
Ok(())
}
/// List all replicas for a repo, oldest registration first.
pub async fn list_replicas(&self, repo_id: &str) -> Result<Vec<RepoReplica>> {
let rows = sqlx::query(
"SELECT replica_did, replica_url, registered_at
FROM repo_replicas
WHERE repo_id = $1
ORDER BY registered_at ASC",
)
.bind(repo_id)
.fetch_one(&self.pool)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| RepoReplica {
replica_did: r.get("replica_did"),
replica_url: r.get("replica_url"),
registered_at: r.get("registered_at"),
})
.collect())
}
/// Count replicas registered for a repo.
pub async fn count_replicas(&self, repo_id: &str) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM repo_replicas WHERE repo_id = $1")
.bind(repo_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt"))
}
/// Check whether a specific agent has starred a repo.
#[allow(dead_code)]
pub async fn is_starred(&self, repo_id: &str, agent_did: &str) -> Result<bool> {
let row = sqlx::query(
"SELECT 1 FROM repo_stars WHERE repo_id = $1 AND agent_did = $2",
)
.bind(repo_id)
.bind(agent_did)
.fetch_optional(&self.pool)
.await?;
let row = sqlx::query("SELECT 1 FROM repo_stars WHERE repo_id = $1 AND agent_did = $2")
.bind(repo_id)
.bind(agent_did)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
}
@@ -1791,12 +2019,10 @@ impl Db {
}
pub async fn get_bounty(&self, id: &str) -> Result<Option<BountyRecord>> {
let row = sqlx::query(
"SELECT * FROM bounties WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let row = sqlx::query("SELECT * FROM bounties WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| self.bounty_from_row(&r)))
}
@@ -1838,7 +2064,13 @@ impl Db {
Ok(rows.iter().map(|r| self.bounty_from_row(r)).collect())
}
pub async fn claim_bounty(&self, id: &str, claimant_did: &str, claimant_wallet: Option<&str>, claimed_at: &str) -> Result<()> {
pub async fn claim_bounty(
&self,
id: &str,
claimant_did: &str,
claimant_wallet: Option<&str>,
claimed_at: &str,
) -> Result<()> {
sqlx::query(
"UPDATE bounties SET claimant_did=$1, claimant_wallet=$2, claimed_at=$3, status='claimed' WHERE id=$4 AND status='open'",
)
@@ -1863,7 +2095,12 @@ impl Db {
Ok(())
}
pub async fn approve_bounty(&self, id: &str, completed_at: &str, tx_hash: Option<&str>) -> Result<()> {
pub async fn approve_bounty(
&self,
id: &str,
completed_at: &str,
tx_hash: Option<&str>,
) -> Result<()> {
sqlx::query(
"UPDATE bounties SET completed_at=$1, tx_hash=$2, status='completed' WHERE id=$3 AND status='submitted'",
)
@@ -1876,12 +2113,10 @@ impl Db {
}
pub async fn cancel_bounty(&self, id: &str) -> Result<()> {
sqlx::query(
"UPDATE bounties SET status='cancelled' WHERE id=$1 AND status='open'",
)
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE bounties SET status='cancelled' WHERE id=$1 AND status='open'")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -1920,9 +2155,16 @@ impl Db {
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| {
(r.get::<String, _>("claimant_did"), r.get::<i64, _>("cnt"), r.get::<i64, _>("total"))
}).collect())
Ok(rows
.iter()
.map(|r| {
(
r.get::<String, _>("claimant_did"),
r.get::<i64, _>("cnt"),
r.get::<i64, _>("total"),
)
})
.collect())
}
fn bounty_from_row(&self, r: &sqlx::postgres::PgRow) -> BountyRecord {
+6 -30
View File
@@ -49,36 +49,12 @@ impl IntoResponse for AppError {
"repo_exists",
format!("repository '{r}' already exists"),
),
AppError::NotFound(msg) => (
StatusCode::NOT_FOUND,
"not_found",
msg.clone(),
),
AppError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
"not_an_agent",
msg.clone(),
),
AppError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
"forbidden",
msg.clone(),
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"bad_request",
msg.clone(),
),
AppError::Git(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
"git_error",
msg.clone(),
),
AppError::Db(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"db_error",
e.to_string(),
),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
+43 -24
View File
@@ -25,7 +25,9 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
let mut child = hash_output;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin.write_all(json.as_bytes()).context("failed to write to git hash-object stdin")?;
stdin
.write_all(json.as_bytes())
.context("failed to write to git hash-object stdin")?;
}
let output = child.wait_with_output().context("git hash-object failed")?;
@@ -34,8 +36,8 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
anyhow::bail!("git hash-object failed: {stderr}");
}
let hash = String::from_utf8(output.stdout)
.context("git hash-object output is not valid UTF-8")?;
let hash =
String::from_utf8(output.stdout).context("git hash-object output is not valid UTF-8")?;
let hash = hash.trim();
// Update the ref
@@ -58,7 +60,11 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
// List all refs under refs/gitlawb/issues/
let list_output = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", "refs/gitlawb/issues/"])
.args([
"for-each-ref",
"--format=%(refname)",
"refs/gitlawb/issues/",
])
.current_dir(repo_path)
.output()
.context("failed to run git for-each-ref")?;
@@ -121,10 +127,7 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
}
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
match matches.len() {
0 => Ok(None),
@@ -156,8 +159,8 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
let raw = get_issue(repo_path, &full_id)?
.expect("ref existed in resolve but not in get — should be impossible");
let mut issue: serde_json::Value = serde_json::from_str(&raw)
.context("invalid issue JSON in git ref")?;
let mut issue: serde_json::Value =
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
issue["status"] = serde_json::Value::String("closed".to_string());
let updated = serde_json::to_string(&issue).context("failed to serialize updated issue")?;
@@ -193,15 +196,6 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
fn init_bare_repo(dir: &TempDir) {
Command::new("git")
.args(["init", "--bare"])
.current_dir(dir.path())
.output()
.unwrap();
// git hash-object -w needs a non-bare repo; use a working repo instead
}
fn init_repo(dir: &TempDir) {
Command::new("git")
.args(["init"])
@@ -225,7 +219,12 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
let resolved = resolve_issue_id(dir.path(), full_id).unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
@@ -235,7 +234,12 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
@@ -252,8 +256,18 @@ mod tests {
fn test_resolve_ambiguous_prefix_errors() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345-aaaa-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-bbbb-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
create_issue(
dir.path(),
"abc12345-aaaa-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(
dir.path(),
"abc12345-bbbb-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ambiguous"));
@@ -264,7 +278,12 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "def99999-0000-0000-0000-000000000000";
create_issue(dir.path(), full_id, r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
create_issue(
dir.path(),
full_id,
r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
let updated = close_issue(dir.path(), "def99999").unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
+3 -3
View File
@@ -1,5 +1,5 @@
pub mod store;
pub mod smart_http;
pub mod issues;
pub mod tigris;
pub mod repo_store;
pub mod smart_http;
pub mod store;
pub mod tigris;
+295 -17
View File
@@ -34,7 +34,12 @@ pub struct RepoStore {
impl RepoStore {
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
Self { repos_dir, tigris, pool, migrated: Arc::new(Mutex::new(HashSet::new())) }
Self {
repos_dir,
tigris,
pool,
migrated: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Ensure a repo is available on local disk, downloading from Tigris if needed.
@@ -42,7 +47,7 @@ impl RepoStore {
/// spawned to lazily migrate it (on-demand migration for pre-Tigris repos).
/// Returns the local path to the bare repo.
pub async fn acquire(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
// Fast path: repo exists locally
if local_path.exists() {
@@ -87,10 +92,15 @@ impl RepoStore {
if let Some(ref tigris) = self.tigris {
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
debug!(repo = %repo_name, "cache miss — downloading from tigris");
tigris.download(&owner_slug, repo_name, &local_path).await
tigris
.download(&owner_slug, repo_name, &local_path)
.await
.context("downloading repo from tigris")?;
// Mark as migrated since we just downloaded it
self.migrated.lock().await.insert(format!("{owner_slug}/{repo_name}"));
self.migrated
.lock()
.await
.insert(format!("{owner_slug}/{repo_name}"));
return Ok(local_path);
}
}
@@ -105,12 +115,14 @@ impl RepoStore {
/// `git-receive-pack`) so the client sees the same refs that `acquire_write()`
/// will operate on.
pub async fn acquire_fresh(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
if let Some(ref tigris) = self.tigris {
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
debug!(repo = %repo_name, "acquire_fresh: downloading latest from tigris");
tigris.download(&owner_slug, repo_name, &local_path).await
tigris
.download(&owner_slug, repo_name, &local_path)
.await
.context("downloading repo from tigris (fresh)")?;
return Ok(local_path);
}
@@ -123,7 +135,7 @@ impl RepoStore {
/// Take a write lock (Postgres advisory lock), ensure repo is local, return guard.
/// The lock prevents concurrent writes to the same repo across machines.
pub async fn acquire_write(&self, owner_did: &str, repo_name: &str) -> Result<RepoWriteGuard> {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
let lock_key = advisory_lock_key(&owner_slug, repo_name);
// Acquire Postgres advisory lock with retry using pg_try_advisory_lock
@@ -152,7 +164,9 @@ impl RepoStore {
if let Some(ref tigris) = self.tigris {
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
debug!(repo = %repo_name, "write acquire: downloading latest from tigris");
tigris.download(&owner_slug, repo_name, &local_path).await
tigris
.download(&owner_slug, repo_name, &local_path)
.await
.context("downloading repo from tigris for write")?;
}
}
@@ -169,10 +183,9 @@ impl RepoStore {
/// Initialize a new bare repo on local disk and upload to Tigris.
pub async fn init(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
store::init_bare(&local_path)
.context("initializing bare repo")?;
store::init_bare(&local_path).context("initializing bare repo")?;
// Upload to Tigris in background
if let Some(ref tigris) = self.tigris {
@@ -194,7 +207,13 @@ impl RepoStore {
/// Call this after any operation that modifies the git repo on disk.
pub async fn release_after_write(&self, owner_did: &str, repo_name: &str) {
if let Some(ref tigris) = self.tigris {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
let (owner_slug, local_path) = match self.local_path(owner_did, repo_name) {
Ok(p) => p,
Err(e) => {
warn!(repo = %repo_name, err = %e, "rejected unsafe path in release_after_write");
return;
}
};
if let Err(e) = tigris.upload(&owner_slug, repo_name, &local_path).await {
warn!(repo = %repo_name, err = %e, "failed to upload repo to tigris after write");
}
@@ -202,13 +221,106 @@ impl RepoStore {
}
/// Compute the local disk path and owner slug for a repo.
fn local_path(&self, owner_did: &str, repo_name: &str) -> (String, PathBuf) {
let owner_slug = owner_did.replace(':', "_").replace('/', "_");
let local_path = self.repos_dir.join(&owner_slug).join(format!("{repo_name}.git"));
(owner_slug, local_path)
///
/// Three-layer defence against path traversal:
/// 1. Strict allowlist on `owner_did` and `repo_name` (no `..`, slashes,
/// null bytes, leading dots; length-bounded).
/// 2. The joined path must remain rooted at `repos_dir`.
/// 3. Every component of the joined path must be `Component::Normal`
/// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir`
/// segment is rejected. This is the CodeQL-recognised barrier
/// pattern for `rust/path-injection`.
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
validate_path_components(owner_did, repo_name)?;
let owner_slug = owner_did.replace([':', '/'], "_");
let local_path = self
.repos_dir
.join(&owner_slug)
.join(format!("{repo_name}.git"));
if !local_path.starts_with(&self.repos_dir) {
anyhow::bail!(
"computed repo path escaped repos_dir: {}",
local_path.display()
);
}
// Explicit component walk — sanitisation barrier that static analysers
// (CodeQL `rust/path-injection`) recognise. The path must be composed
// entirely of Normal segments after the root prefix; any ParentDir or
// CurDir component is a traversal attempt.
for component in local_path.components() {
use std::path::Component;
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
Component::ParentDir => {
anyhow::bail!("path contains parent-directory component");
}
Component::CurDir => {
anyhow::bail!("path contains current-directory component");
}
}
}
Ok((owner_slug, local_path))
}
}
/// Strict allowlist validator for `owner_did` and `repo_name`.
///
/// Rejects any character that isn't explicitly safe, plus length and
/// special-sequence checks (`..`, leading `.`, leading `-`).
fn validate_path_components(owner_did: &str, repo_name: &str) -> Result<()> {
validate_owner_did(owner_did)?;
validate_repo_name(repo_name)?;
Ok(())
}
fn validate_owner_did(owner_did: &str) -> Result<()> {
if owner_did.is_empty() {
anyhow::bail!("owner_did is empty");
}
if owner_did.len() > 256 {
anyhow::bail!("owner_did exceeds 256 chars");
}
// DIDs are `did:method:identifier` — `did:key:z6Mk...`, `did:web:host:user`, etc.
// Allow alnum + `:`, `.`, `_`, `-`. Reject `..` substring and any `/` or `\`.
if owner_did.contains("..") {
anyhow::bail!("owner_did contains '..' sequence");
}
for ch in owner_did.chars() {
let ok = ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-');
if !ok {
anyhow::bail!("owner_did contains disallowed character: {ch:?}");
}
}
Ok(())
}
fn validate_repo_name(repo_name: &str) -> Result<()> {
if repo_name.is_empty() {
anyhow::bail!("repo_name is empty");
}
if repo_name.len() > 100 {
anyhow::bail!("repo_name exceeds 100 chars");
}
// Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
if repo_name.contains("..") {
anyhow::bail!("repo_name contains '..' sequence");
}
if repo_name.starts_with('.') || repo_name.starts_with('-') {
anyhow::bail!("repo_name must not start with '.' or '-'");
}
for ch in repo_name.chars() {
let ok = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
if !ok {
anyhow::bail!("repo_name contains disallowed character: {ch:?}");
}
}
Ok(())
}
/// Guard returned by `acquire_write()`. Holds the Postgres advisory lock and
/// uploads to Tigris + releases the lock on `release()`.
pub struct RepoWriteGuard {
@@ -230,7 +342,10 @@ impl RepoWriteGuard {
pub async fn release(self) {
// Upload to Tigris
if let Some(ref tigris) = self.tigris {
if let Err(e) = tigris.upload(&self.owner_slug, &self.repo_name, &self.local_path).await {
if let Err(e) = tigris
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
.await
{
warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
}
}
@@ -251,3 +366,166 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
repo_name.hash(&mut hasher);
hasher.finish() as i64
}
#[cfg(test)]
mod tests {
use super::*;
// ── repo_name validation ───────────────────────────────────────────────
#[test]
fn repo_name_accepts_normal_names() {
for name in [
"hello",
"hello-world",
"hello_world",
"hello.world",
"Repo123",
"a",
] {
validate_repo_name(name).unwrap_or_else(|e| panic!("{name} should be valid: {e}"));
}
}
#[test]
fn repo_name_rejects_empty() {
assert!(validate_repo_name("").is_err());
}
#[test]
fn repo_name_rejects_path_traversal_dotdot() {
for name in ["..", "../etc", "../../passwd", "foo/../bar", "a..b"] {
assert!(
validate_repo_name(name).is_err(),
"{name:?} must be rejected"
);
}
}
#[test]
fn repo_name_rejects_slashes() {
for name in ["foo/bar", "foo\\bar", "/abs", "a/b/c"] {
assert!(
validate_repo_name(name).is_err(),
"{name:?} must be rejected"
);
}
}
#[test]
fn repo_name_rejects_leading_dot_or_dash() {
for name in [".hidden", ".", "-foo"] {
assert!(
validate_repo_name(name).is_err(),
"{name:?} must be rejected"
);
}
}
#[test]
fn repo_name_rejects_null_byte() {
assert!(validate_repo_name("foo\0bar").is_err());
}
#[test]
fn repo_name_rejects_overlong() {
let long = "a".repeat(101);
assert!(validate_repo_name(&long).is_err());
}
// ── owner_did validation ───────────────────────────────────────────────
#[test]
fn owner_did_accepts_did_key() {
validate_owner_did("did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr").unwrap();
}
#[test]
fn owner_did_accepts_did_web_with_dots() {
validate_owner_did("did:web:example.com:user").unwrap();
}
#[test]
fn owner_did_rejects_empty() {
assert!(validate_owner_did("").is_err());
}
#[test]
fn owner_did_rejects_path_traversal() {
for did in [
"did:key:..",
"did:key:../../etc",
"..",
"did:key:foo/../bar",
] {
assert!(validate_owner_did(did).is_err(), "{did:?} must be rejected");
}
}
#[test]
fn owner_did_rejects_slashes_and_backslashes() {
for did in ["did:key:foo/bar", "did:key:foo\\bar", "did/key/foo"] {
assert!(validate_owner_did(did).is_err(), "{did:?} must be rejected");
}
}
#[test]
fn owner_did_rejects_null_byte() {
assert!(validate_owner_did("did:key:z6Mk\0evil").is_err());
}
#[test]
fn owner_did_rejects_overlong() {
let long = format!("did:key:{}", "z".repeat(260));
assert!(validate_owner_did(&long).is_err());
}
// ── end-to-end local_path ──────────────────────────────────────────────
fn make_store() -> RepoStore {
// We only exercise the path-construction code, which doesn't touch
// the pool or the network. Fabricate a pool reference via PgPool::connect_lazy
// so we don't need a live DB.
let pool = sqlx::PgPool::connect_lazy("postgres://invalid").unwrap();
RepoStore::new(PathBuf::from("/var/lib/gitlawb/repos"), None, pool)
}
#[tokio::test]
async fn local_path_resolves_safe_inputs() {
let store = make_store();
let (slug, path) = store
.local_path(
"did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"hello",
)
.unwrap();
assert_eq!(
slug,
"did_key_z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr"
);
assert!(path.starts_with("/var/lib/gitlawb/repos"));
assert!(path.ends_with("hello.git"));
}
#[tokio::test]
async fn local_path_rejects_traversal_in_repo_name() {
let store = make_store();
for bad in ["../etc/passwd", "..", "../../shadow"] {
assert!(
store.local_path("did:key:z6MkAlice", bad).is_err(),
"repo_name={bad:?} must be rejected"
);
}
}
#[tokio::test]
async fn local_path_rejects_traversal_in_owner_did() {
let store = make_store();
for bad in ["did:key:..", "..", "did/key/foo"] {
assert!(
store.local_path(bad, "hello").is_err(),
"owner_did={bad:?} must be rejected"
);
}
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
use std::path::Path;
use std::process::Stdio;
use anyhow::{bail, Result};
use axum::body::Body;
use axum::http::StatusCode;
use axum::response::Response;
use bytes::Bytes;
use std::path::Path;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use anyhow::{bail, Result};
/// Handle `GET /:owner/:repo/info/refs?service=git-upload-pack`
/// or `?service=git-receive-pack`
+42 -13
View File
@@ -1,6 +1,6 @@
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, Context, bail};
/// Initialize a new bare git repository with SHA-1 object format (default).
///
@@ -107,7 +107,8 @@ pub fn resolve_head(repo_path: &Path, preferred_branch: &str) -> String {
// 3. Walk all refs — prefer main/master, then take the first one
if let Ok(refs) = list_refs(repo_path) {
let branches: Vec<_> = refs.iter()
let branches: Vec<_> = refs
.iter()
.filter(|(r, _)| r.starts_with("refs/heads/"))
.collect();
// Preferred names in order
@@ -199,7 +200,13 @@ pub fn ls_tree(repo_path: &Path, refname: &str, tree_path: &str) -> Result<Vec<T
let kind = parts.next()?.to_string();
let hash = parts.next()?.to_string();
let size: Option<u64> = parts.next().and_then(|s| s.parse().ok());
Some(TreeEntry { mode, kind, hash, path: name.to_string(), size })
Some(TreeEntry {
mode,
kind,
hash,
path: name.to_string(),
size,
})
})
.collect();
@@ -239,7 +246,9 @@ pub struct CommitInfo {
fn serialize_timestamp<S: serde::Serializer>(ts: &i64, s: S) -> Result<S::Ok, S::Error> {
use chrono::TimeZone;
let dt = chrono::Utc.timestamp_opt(*ts, 0).single()
let dt = chrono::Utc
.timestamp_opt(*ts, 0)
.single()
.unwrap_or_else(chrono::Utc::now);
s.serialize_str(&dt.to_rfc3339())
}
@@ -275,7 +284,9 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
return Ok(None);
}
let obj_type = String::from_utf8_lossy(&type_output.stdout).trim().to_string();
let obj_type = String::from_utf8_lossy(&type_output.stdout)
.trim()
.to_string();
// Read the raw content bytes
let content_output = Command::new("git")
@@ -305,7 +316,13 @@ pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -
/// Merge source_branch into target_branch in a bare repo using a temporary worktree.
/// Returns the new merge commit hash.
pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str, author_did: &str, pr_title: &str) -> Result<String> {
pub fn merge_branch(
repo_path: &Path,
target_branch: &str,
source_branch: &str,
author_did: &str,
pr_title: &str,
) -> Result<String> {
let worktree_path = repo_path.join("_merge_worktree");
// Clean up any leftover worktree
@@ -324,13 +341,24 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
.output()
.context("failed to create worktree")?;
if !wt.status.success() {
bail!("git worktree add failed: {}", String::from_utf8_lossy(&wt.stderr));
bail!(
"git worktree add failed: {}",
String::from_utf8_lossy(&wt.stderr)
);
}
// Run merge in worktree
let merge = Command::new("git")
.args(["merge", "--no-ff", source_branch, "-m",
&format!("Merge branch '{}' into {} ({})", source_branch, target_branch, pr_title)])
.args([
"merge",
"--no-ff",
source_branch,
"-m",
&format!(
"Merge branch '{}' into {} ({})",
source_branch, target_branch, pr_title
),
])
.current_dir(&worktree_path)
.env("GIT_AUTHOR_NAME", author_did)
.env("GIT_AUTHOR_EMAIL", format!("{}@gitlawb", author_did))
@@ -349,7 +377,10 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
let _ = std::fs::remove_dir_all(&worktree_path);
if !success {
bail!("git merge failed: {}", String::from_utf8_lossy(&merge.stderr));
bail!(
"git merge failed: {}",
String::from_utf8_lossy(&merge.stderr)
);
}
// Get new HEAD of target branch
@@ -365,8 +396,6 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
// Sanitize the DID for use as a directory name
let owner_slug = owner_did
.replace(':', "_")
.replace('/', "_");
let owner_slug = owner_did.replace([':', '/'], "_");
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
}
+25 -8
View File
@@ -23,7 +23,10 @@ impl TigrisClient {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
info!(bucket = %bucket, "tigris storage client initialized");
Ok(Self { s3, bucket: bucket.to_string() })
Ok(Self {
s3,
bucket: bucket.to_string(),
})
}
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
@@ -34,7 +37,9 @@ impl TigrisClient {
/// Check if a repo archive exists in Tigris.
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
let key = Self::repo_key(owner_slug, repo_name);
match self.s3.head_object()
match self
.s3
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
@@ -42,7 +47,7 @@ impl TigrisClient {
{
Ok(_) => Ok(true),
Err(e) => {
if e.as_service_error().map_or(false, |e| e.is_not_found()) {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
Ok(false)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
@@ -67,7 +72,8 @@ impl TigrisClient {
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
self.s3.put_object()
self.s3
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
@@ -81,18 +87,28 @@ impl TigrisClient {
}
/// Download a repo archive from Tigris and extract to local disk.
pub async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
pub async fn download(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
let resp = self.s3.get_object()
let resp = self
.s3
.get_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.context(format!("tigris GET {key}"))?;
let data = resp.body.collect().await
let data = resp
.body
.collect()
.await
.context("reading tigris response body")?
.into_bytes()
.to_vec();
@@ -114,7 +130,8 @@ impl TigrisClient {
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3.delete_object()
self.s3
.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
+3 -6
View File
@@ -48,8 +48,7 @@ impl MutationRoot {
assignee_did: String,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.claim_task(&id, &assignee_did)
.await
@@ -72,8 +71,7 @@ impl MutationRoot {
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.finish_task(&id, "completed", input.result.as_deref())
.await
@@ -96,8 +94,7 @@ impl MutationRoot {
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let reason = input.reason.unwrap_or_default();
let task = db
.finish_task(&id, "failed", Some(&reason))
@@ -1,5 +1,5 @@
use async_graphql::{Context, Subscription};
use async_graphql::futures_util::Stream;
use async_graphql::{Context, Subscription};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt as _;
@@ -22,7 +22,7 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let rf = repo.clone();
match r {
Ok(ev) if rf.as_deref().map_or(true, |r| ev.repo == r) => Some(RefUpdateType {
Ok(ev) if rf.as_deref().is_none_or(|r| ev.repo == r) => Some(RefUpdateType {
repo: ev.repo,
ref_name: ev.ref_name,
old_sha: ev.old_sha,
@@ -47,15 +47,13 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let tf = task_id.clone();
match r {
Ok(ev) if tf.as_deref().map_or(true, |id| ev.task_id == id) => {
Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
})
}
Ok(ev) if tf.as_deref().is_none_or(|id| ev.task_id == id) => Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
}),
_ => None,
}
})
+1 -1
View File
@@ -63,7 +63,7 @@ pub async fn pin_git_object(ipfs_api: &str, sha256_hex: &str, data: &[u8]) -> Re
let v: serde_json::Value = serde_json::from_str(line).ok()?;
v["Hash"].as_str().map(|s| s.to_string())
})
.last()
.next_back()
.unwrap_or(expected_cid.clone());
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
+78 -41
View File
@@ -1,6 +1,7 @@
mod api;
mod arweave;
mod auth;
mod bootstrap;
mod cert;
mod config;
mod db;
@@ -16,11 +17,11 @@ mod state;
mod sync;
mod webhooks;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::info;
use tracing::{info, warn};
use gitlawb_core::identity::Keypair;
@@ -38,33 +39,60 @@ async fn main() -> Result<()> {
)
.init();
let config = Config::parse();
let mut config = Config::parse();
// Merge the embedded seed list of public network nodes into the runtime
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
bootstrap::merge_seeds(&mut config);
// Load or generate the node's identity keypair
let keypair = load_or_create_keypair(&config)?;
let node_did = keypair.did();
info!("╔══════════════════════════════════════════╗");
info!("║ gitlawb node v{} ║", env!("CARGO_PKG_VERSION"));
info!(
"║ gitlawb node v{} ║",
env!("CARGO_PKG_VERSION")
);
info!("╚══════════════════════════════════════════╝");
info!(did = %node_did, "node identity");
info!(addr = %config.bind_addr(), "listening");
// Connect to PostgreSQL database
let db = Arc::new(Db::connect(&config.database_url)
.await
.context("failed to connect to database")?);
let db = Arc::new(
Db::connect(&config.database_url)
.await
.context("failed to connect to database")?,
);
// Prune peer rows that point back at this node (stale self-loop entries)
if let Some(public_url) = config.public_url.as_deref() {
match db.prune_self_peers(public_url).await {
Ok(0) => {}
Ok(n) => info!(removed = n, public_url, "pruned self-loop peer rows"),
Err(e) => warn!(err = %e, "prune_self_peers failed (non-fatal)"),
}
}
// Ensure repos directory exists
std::fs::create_dir_all(&config.repos_dir)
.context("failed to create repos directory")?;
std::fs::create_dir_all(&config.repos_dir).context("failed to create repos directory")?;
// Start libp2p swarm (if p2p_port > 0)
let p2p_handle = if config.p2p_port > 0 {
let bootstrap_addrs = config.p2p_bootstrap.iter()
let bootstrap_addrs = config
.p2p_bootstrap
.iter()
.filter_map(|s| s.parse().ok())
.collect();
match p2p::start(&node_did.to_string(), config.p2p_port, bootstrap_addrs, Arc::clone(&db), config.auto_sync).await {
match p2p::start(
&node_did.to_string(),
config.p2p_port,
bootstrap_addrs,
Arc::clone(&db),
config.auto_sync,
)
.await
{
Ok(handle) => {
info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started");
Some(Arc::new(handle))
@@ -79,9 +107,11 @@ async fn main() -> Result<()> {
None
};
let http_client = Arc::new(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?);
let http_client = Arc::new(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?,
);
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
@@ -114,11 +144,8 @@ async fn main() -> Result<()> {
None
};
let repo_store = git::repo_store::RepoStore::new(
config.repos_dir.clone(),
tigris,
db.pool().clone(),
);
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
let state = AppState {
config: Arc::new(config.clone()),
@@ -135,12 +162,16 @@ async fn main() -> Result<()> {
};
let router = server::build_router(state.clone());
let listener = TcpListener::bind(config.bind_addr()).await
let listener = TcpListener::bind(config.bind_addr())
.await
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
info!("✓ node started — did:{}", node_did);
info!(" repos dir: {}", config.repos_dir.display());
info!(" database: PostgreSQL ({})", &config.database_url.split('@').last().unwrap_or("?"));
info!(
" database: PostgreSQL ({})",
&config.database_url.split('@').next_back().unwrap_or("?")
);
// Publish our DID record to the Kademlia DHT shortly after startup
if let Some(p2p) = &state.p2p {
@@ -161,7 +192,7 @@ async fn main() -> Result<()> {
}
// Spawn background gossip: announce to bootstrap peers, then ping known peers periodically
if !config.bootstrap_peers.is_empty() || true {
{
let gossip_state = state.clone();
let bootstrap_peers = config.bootstrap_peers.clone();
tokio::spawn(async move {
@@ -180,20 +211,18 @@ async fn main() -> Result<()> {
&& !state.config.operator_private_key.is_empty()
{
match build_operator_client(&state.config, &state.node_did.to_string()) {
Ok(client) => {
match operator::startup_check(&client).await {
Ok(_) => {
let arc_client = Arc::new(client);
arc_client.spawn_heartbeat_loop();
}
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode operator check failed"));
}
tracing::warn!(err = %e, "operator startup check failed — continuing without heartbeat loop");
}
Ok(client) => match operator::startup_check(&client).await {
Ok(_) => {
let arc_client = Arc::new(client);
arc_client.spawn_heartbeat_loop();
}
}
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode operator check failed"));
}
tracing::warn!(err = %e, "operator startup check failed — continuing without heartbeat loop");
}
},
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode: failed to build operator client"));
@@ -209,7 +238,10 @@ async fn main() -> Result<()> {
Ok(())
}
fn build_operator_client(config: &config::Config, node_did: &str) -> Result<operator::OperatorClient> {
fn build_operator_client(
config: &config::Config,
node_did: &str,
) -> Result<operator::OperatorClient> {
use alloy::primitives::Address;
use std::str::FromStr;
@@ -260,7 +292,9 @@ async fn gossip_task(state: AppState, bootstrap_peers: Vec<String>) {
}
}
}
Err(e) => tracing::warn!(url = %announce_url, err = %e, "failed to announce to bootstrap peer"),
Err(e) => {
tracing::warn!(url = %announce_url, err = %e, "failed to announce to bootstrap peer")
}
}
}
@@ -274,7 +308,10 @@ async fn gossip_task(state: AppState, bootstrap_peers: Vec<String>) {
};
for peer in peers {
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = client.get(&url).send().await
let ok = client
.get(&url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
@@ -288,13 +325,13 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
if key_path.exists() {
let pem = std::fs::read_to_string(&key_path)
.with_context(|| format!("failed to read key from {}", key_path.display()))?;
let kp = Keypair::from_pem(&pem)
.map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?;
let kp = Keypair::from_pem(&pem).map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?;
info!(path = %key_path.display(), "loaded existing identity");
Ok(kp)
} else {
let kp = Keypair::generate();
let pem = kp.to_pem()
let pem = kp
.to_pem()
.map_err(|e| anyhow::anyhow!("failed to serialize key: {e}"))?;
if let Some(parent) = key_path.parent() {
+1 -3
View File
@@ -129,9 +129,7 @@ impl OperatorClient {
.rpc_url
.parse()
.with_context(|| format!("invalid RPC URL: {}", self.cfg.rpc_url))?;
let provider = ProviderBuilder::new()
.wallet(wallet)
.connect_http(rpc_url);
let provider = ProviderBuilder::new().wallet(wallet).connect_http(rpc_url);
let contract = IGitlawbNodeStaking::new(self.cfg.contract_address, provider);
let pending = contract
+49 -20
View File
@@ -8,7 +8,7 @@
//! The node's PeerId is derived from its Ed25519 identity keypair,
//! so the gitlawb DID and libp2p PeerId share the same key.
use std::collections::{HashMap, hash_map::DefaultHasher};
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::time::Duration;
@@ -17,11 +17,9 @@ use anyhow::Result;
use chrono::Utc;
use libp2p::futures::StreamExt;
use libp2p::{
gossipsub, identify, kad,
mdns,
noise, tcp, yamux,
gossipsub, identify, kad, mdns, noise,
swarm::{NetworkBehaviour, SwarmEvent},
Multiaddr, PeerId,
tcp, yamux, Multiaddr, PeerId,
};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
@@ -65,6 +63,15 @@ pub struct DidRecord {
pub timestamp: String,
}
/// Snapshot of the libp2p swarm state for observability.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SwarmStatus {
pub connected_peers: usize,
pub gossipsub_mesh_peers: usize,
pub gossipsub_all_peers: usize,
pub listen_addrs: Vec<String>,
}
/// Commands sent to the swarm task from the rest of the node.
#[derive(Debug)]
pub enum P2pCommand {
@@ -79,7 +86,12 @@ pub enum P2pCommand {
/// Store a DID record in the Kademlia DHT (fire-and-forget)
PutDid(DidRecord),
/// Look up a DID in the Kademlia DHT; reply on the oneshot sender
GetDid { did: String, reply: oneshot::Sender<Option<DidRecord>> },
GetDid {
did: String,
reply: oneshot::Sender<Option<DidRecord>>,
},
/// Get a snapshot of the swarm status
GetStatus { reply: oneshot::Sender<SwarmStatus> },
}
/// Handle returned to the rest of the node for sending commands to the swarm.
@@ -96,7 +108,10 @@ impl P2pHandle {
#[allow(dead_code)]
pub async fn add_peer(&self, peer_id: PeerId, addr: Multiaddr) {
let _ = self.tx.send(P2pCommand::AddKnownPeer { peer_id, addr }).await;
let _ = self
.tx
.send(P2pCommand::AddKnownPeer { peer_id, addr })
.await;
}
#[allow(dead_code)]
@@ -109,6 +124,15 @@ impl P2pHandle {
let _ = self.tx.send(P2pCommand::PutDid(record)).await;
}
pub async fn status(&self) -> Option<SwarmStatus> {
let (tx, rx) = oneshot::channel();
let _ = self.tx.send(P2pCommand::GetStatus { reply: tx }).await;
tokio::time::timeout(std::time::Duration::from_secs(2), rx)
.await
.ok()
.and_then(|r| r.ok())
}
/// Look up a DID in the DHT. Returns None if not found or timeout (10s).
pub async fn get_did(&self, did: String) -> Option<DidRecord> {
let (tx, rx) = oneshot::channel();
@@ -212,11 +236,8 @@ pub async fn start(
));
// mDNS — local network peer discovery
let mdns = mdns::tokio::Behaviour::new(
mdns::Config::default(),
peer_id,
)
.expect("mdns behaviour");
let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), peer_id)
.expect("mdns behaviour");
GitlawbBehaviour {
kademlia,
@@ -298,14 +319,12 @@ pub async fn start(
kad::Event::OutboundQueryProgressed { id, result, .. }
)) => {
match result {
kad::QueryResult::GetRecord(Ok(ok)) => {
if let kad::GetRecordOk::FoundRecord(pr) = ok {
if let Some(reply) = pending_get_did.remove(&id) {
let record = serde_json::from_slice::<DidRecord>(
&pr.record.value
).ok();
let _ = reply.send(record);
}
kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(pr))) => {
if let Some(reply) = pending_get_did.remove(&id) {
let record = serde_json::from_slice::<DidRecord>(
&pr.record.value
).ok();
let _ = reply.send(record);
}
}
kad::QueryResult::GetRecord(Err(e)) => {
@@ -391,6 +410,16 @@ pub async fn start(
let query_id = swarm.behaviour_mut().kademlia.get_record(key);
pending_get_did.insert(query_id, reply);
}
P2pCommand::GetStatus { reply } => {
let topic_hash = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC).hash();
let status = SwarmStatus {
connected_peers: swarm.connected_peers().count(),
gossipsub_mesh_peers: swarm.behaviour().gossipsub.mesh_peers(&topic_hash).count(),
gossipsub_all_peers: swarm.behaviour().gossipsub.all_peers().count(),
listen_addrs: swarm.listeners().map(|a| a.to_string()).collect(),
};
let _ = reply.send(status);
}
}
}
}
+33 -5
View File
@@ -133,7 +133,11 @@ pub async fn pin_new_objects(
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
let out = std::process::Command::new("git")
.args(["cat-file", "--batch-all-objects", "--batch-check=%(objectname)"])
.args([
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname)",
])
.current_dir(repo_path)
.output()
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
@@ -159,7 +163,14 @@ mod tests {
#[tokio::test]
async fn test_pin_skipped_when_jwt_empty() {
let client = reqwest::Client::new();
let result = pin_object(&client, "https://uploads.pinata.cloud/v3/files", "", "deadbeef", b"data").await;
let result = pin_object(
&client,
"https://uploads.pinata.cloud/v3/files",
"",
"deadbeef",
b"data",
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "", "empty JWT must return empty CID");
}
@@ -186,7 +197,10 @@ mod tests {
.await;
assert!(result.is_ok(), "pin should succeed: {result:?}");
assert_eq!(result.unwrap(), "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG");
assert_eq!(
result.unwrap(),
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"
);
_mock.assert_async().await;
}
@@ -201,7 +215,14 @@ mod tests {
.await;
let client = reqwest::Client::new();
let result = pin_object(&client, &server.url(), "bad-jwt", "deadbeef00000000", b"data").await;
let result = pin_object(
&client,
&server.url(),
"bad-jwt",
"deadbeef00000000",
b"data",
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("401"));
@@ -255,7 +276,14 @@ mod tests {
.await;
let client = reqwest::Client::new();
let result = pin_object(&client, &server.url(), "my-pinata-jwt", "deadbeef00000000", b"data").await;
let result = pin_object(
&client,
&server.url(),
"my-pinata-jwt",
"deadbeef00000000",
b"data",
)
.await;
assert!(result.is_ok());
_mock.assert_async().await;
+212 -59
View File
@@ -1,4 +1,5 @@
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
use axum::extract::DefaultBodyLimit;
use axum::{
extract::State,
middleware,
@@ -6,20 +7,19 @@ use axum::{
routing::{get, post},
Json, Router,
};
use axum::extract::DefaultBodyLimit;
use tower_http::limit::RequestBodyLimitLayer;
use serde_json::json;
use tower_http::trace::{DefaultOnResponse, DefaultOnFailure, TraceLayer};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer};
use tracing::Level;
use crate::api::{agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, protect, pulls, register, repos, resolve, stars, tasks, webhooks};
use crate::api::{
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, protect,
pulls, register, replicas, repos, resolve, stars, tasks, webhooks,
};
use crate::auth;
use crate::state::AppState;
async fn graphql_handler(
State(state): State<AppState>,
req: GraphQLRequest,
) -> GraphQLResponse {
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
state.graphql_schema.execute(req.into_inner()).await.into()
}
@@ -55,19 +55,63 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/repos", post(repos::create_repo))
.route("/api/register", post(register::register))
.route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", post(pulls::merge_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/close", post(pulls::close_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", post(pulls::create_review))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", post(pulls::create_comment))
.route("/api/v1/repos/{owner}/{repo}/hooks", post(webhooks::create_webhook))
.route("/api/v1/repos/{owner}/{repo}/hooks/{id}", axum::routing::delete(webhooks::delete_webhook))
.route("/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", post(protect::protect_branch))
.route("/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", axum::routing::delete(protect::unprotect_branch))
.route("/api/v1/repos/{owner}/{repo}/star", axum::routing::put(stars::star_repo))
.route("/api/v1/repos/{owner}/{repo}/star", axum::routing::delete(stars::unstar_repo))
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
post(pulls::merge_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/close",
post(pulls::close_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
post(pulls::create_review),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
post(pulls::create_comment),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks",
post(webhooks::create_webhook),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks/{id}",
axum::routing::delete(webhooks::delete_webhook),
)
.route(
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
post(protect::protect_branch),
)
.route(
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
axum::routing::delete(protect::unprotect_branch),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
axum::routing::put(stars::star_repo),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
axum::routing::delete(stars::unstar_repo),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
axum::routing::put(replicas::register_replica),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
axum::routing::delete(replicas::unregister_replica),
)
.route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo))
.route("/api/v1/repos/{owner}/{repo}/labels", post(labels::add_label))
.route("/api/v1/repos/{owner}/{repo}/labels/{label}", axum::routing::delete(labels::remove_label))
.route(
"/api/v1/repos/{owner}/{repo}/labels",
post(labels::add_label),
)
.route(
"/api/v1/repos/{owner}/{repo}/labels/{label}",
axum::routing::delete(labels::remove_label),
)
.layer(middleware::from_fn(auth::require_signature));
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
@@ -76,7 +120,10 @@ pub fn build_router(state: AppState) -> Router {
// helper signs requests with RFC 9421 signatures using the agent's keypair.
let pack_limit = state.config.max_pack_bytes;
let git_write_routes = Router::new()
.route("/{owner}/{repo}/git-receive-pack", post(repos::git_receive_pack))
.route(
"/{owner}/{repo}/git-receive-pack",
post(repos::git_receive_pack),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::require_signature));
@@ -87,32 +134,61 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
// ── Arweave permanent anchors ──────────────────────────────────────────
let arweave_routes = Router::new()
.route("/api/v1/arweave/anchors", get(arweave::list_anchors));
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
// ── Bounty routes (write — require HTTP Signature) ─────────────────
let bounty_write_routes = Router::new()
.route("/api/v1/repos/{owner}/{repo}/bounties", post(bounties::create_bounty))
.route(
"/api/v1/repos/{owner}/{repo}/bounties",
post(bounties::create_bounty),
)
.route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty))
.route("/api/v1/bounties/{id}/submit", post(bounties::submit_bounty))
.route("/api/v1/bounties/{id}/approve", post(bounties::approve_bounty))
.route("/api/v1/bounties/{id}/cancel", post(bounties::cancel_bounty))
.route(
"/api/v1/bounties/{id}/submit",
post(bounties::submit_bounty),
)
.route(
"/api/v1/bounties/{id}/approve",
post(bounties::approve_bounty),
)
.route(
"/api/v1/bounties/{id}/cancel",
post(bounties::cancel_bounty),
)
.layer(middleware::from_fn(auth::require_signature));
// ── Bounty routes (read — open) ──────────────────────────────────────
let bounty_read_routes = Router::new()
.route("/api/v1/repos/{owner}/{repo}/bounties", get(bounties::list_repo_bounties))
.route(
"/api/v1/repos/{owner}/{repo}/bounties",
get(bounties::list_repo_bounties),
)
.route("/api/v1/bounties", get(bounties::list_all_bounties))
.route("/api/v1/bounties/{id}", get(bounties::get_bounty))
.route("/api/v1/bounties/{id}/dispute", post(bounties::dispute_bounty))
.route(
"/api/v1/bounties/{id}/dispute",
post(bounties::dispute_bounty),
)
.route("/api/v1/bounties/stats", get(bounties::bounty_stats))
.route("/api/v1/agents/{did}/bounties", get(bounties::agent_bounty_stats));
.route(
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
);
// ── Issue routes ──────────────────────────────────────────────────────
let issue_write_routes = Router::new()
.route("/api/v1/repos/{owner}/{repo}/issues", post(issues::create_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/close", post(issues::close_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/comments", post(issues::create_issue_comment))
.route(
"/api/v1/repos/{owner}/{repo}/issues",
post(issues::create_issue),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/close",
post(issues::close_issue),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
post(issues::create_issue_comment),
)
.layer(middleware::from_fn(auth::require_signature));
// ── Peer discovery routes ─────────────────────────────────────────────
@@ -130,38 +206,99 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/repos", get(repos::list_repos))
.route("/api/v1/repos/federated", get(repos::list_federated_repos))
.route("/api/v1/repos/{owner}/{repo}", get(repos::get_repo))
.route("/api/v1/repos/{owner}/{repo}/commits", get(repos::list_commits))
.route("/api/v1/repos/{owner}/{repo}/tree", get(repos::get_tree_root))
.route("/api/v1/repos/{owner}/{repo}/tree/{*path}", get(repos::get_tree))
.route("/api/v1/repos/{owner}/{repo}/blob/{*path}", get(repos::get_blob))
.route("/api/v1/repos/{owner}/{repo}/issues", get(issues::list_issues))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}", get(issues::get_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/comments", get(issues::list_issue_comments))
.route("/api/v1/repos/{owner}/{repo}/labels", get(labels::list_labels))
.route(
"/api/v1/repos/{owner}/{repo}/commits",
get(repos::list_commits),
)
.route(
"/api/v1/repos/{owner}/{repo}/tree",
get(repos::get_tree_root),
)
.route(
"/api/v1/repos/{owner}/{repo}/tree/{*path}",
get(repos::get_tree),
)
.route(
"/api/v1/repos/{owner}/{repo}/blob/{*path}",
get(repos::get_blob),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues",
get(issues::list_issues),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}",
get(issues::get_issue),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
get(issues::list_issue_comments),
)
.route(
"/api/v1/repos/{owner}/{repo}/labels",
get(labels::list_labels),
)
.route("/api/v1/repos/{owner}/{repo}/certs", get(certs::list_certs))
.route("/api/v1/repos/{owner}/{repo}/certs/{id}", get(certs::get_cert))
.route("/api/v1/repos/{owner}/{repo}/events", get(events::list_repo_events))
.route(
"/api/v1/repos/{owner}/{repo}/certs/{id}",
get(certs::get_cert),
)
.route(
"/api/v1/repos/{owner}/{repo}/events",
get(events::list_repo_events),
)
.route("/api/v1/agents", get(agents::list_agents))
.route("/api/v1/agents/{did}", get(agents::show_agent))
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
.route("/api/v1/events/ref-updates", get(events::list_ref_updates))
.route("/api/v1/resolve/{did}", get(resolve::resolve_did))
.route("/api/v1/repos/{owner}/{repo}/pulls", get(pulls::list_prs))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}", get(pulls::get_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff", get(pulls::get_pr_diff))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", get(pulls::list_reviews))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", get(pulls::list_comments))
.route("/api/v1/repos/{owner}/{repo}/hooks", get(webhooks::list_webhooks))
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}",
get(pulls::get_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/diff",
get(pulls::get_pr_diff),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
get(pulls::list_reviews),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
get(pulls::list_comments),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks",
get(webhooks::list_webhooks),
)
.route("/api/v1/repos/{owner}/{repo}/refs", get(repos::list_refs))
.route("/api/v1/repos/{owner}/{repo}/branches/protected", get(protect::list_protected_branches))
.route("/api/v1/repos/{owner}/{repo}/changelog", get(changelog::get_changelog))
.route("/api/v1/repos/{owner}/{repo}/star", get(stars::get_star_status))
.route(
"/api/v1/repos/{owner}/{repo}/branches/protected",
get(protect::list_protected_branches),
)
.route(
"/api/v1/repos/{owner}/{repo}/changelog",
get(changelog::get_changelog),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
get(stars::get_star_status),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
)
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs));
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
let git_read_routes = Router::new()
.route("/{owner}/{repo}/git-upload-pack", post(repos::git_upload_pack))
.route(
"/{owner}/{repo}/git-upload-pack",
post(repos::git_upload_pack),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit));
@@ -222,7 +359,12 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
}
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
let repos = state.db.list_all_repos().await.map(|r| r.len() as i64).unwrap_or(0);
let repos = state
.db
.list_all_repos()
.await
.map(|r| r.len() as i64)
.unwrap_or(0);
let agents = state.db.count_agents().await.unwrap_or(0);
let pushes = state.db.count_pushes().await.unwrap_or(0);
Json(json!({
@@ -237,7 +379,11 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
let did_registry = &state.config.contract_did_registry;
let name_registry = &state.config.contract_name_registry;
let rpc_url = &state.config.chain_rpc_url;
let chain_id: u64 = if rpc_url.contains("sepolia") { 84532 } else { 8453 };
let chain_id: u64 = if rpc_url.contains("sepolia") {
84532
} else {
8453
};
Json(serde_json::json!({
"chain": if chain_id == 8453 { "base" } else { "base-sepolia" },
"chain_id": chain_id,
@@ -255,11 +401,18 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
match &state.p2p {
Some(h) => Json(json!({
"enabled": true,
"peer_id": h.local_peer_id.to_string(),
"topics": [crate::p2p::REF_UPDATES_TOPIC],
})),
Some(h) => {
let status = h.status().await;
Json(json!({
"enabled": true,
"peer_id": h.local_peer_id.to_string(),
"topics": [crate::p2p::REF_UPDATES_TOPIC],
"connected_peers": status.as_ref().map(|s| s.connected_peers),
"gossipsub_mesh_peers": status.as_ref().map(|s| s.gossipsub_mesh_peers),
"gossipsub_all_peers": status.as_ref().map(|s| s.gossipsub_all_peers),
"listen_addrs": status.as_ref().map(|s| s.listen_addrs.clone()),
}))
}
None => Json(json!({ "enabled": false })),
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gitlawb_core::did::Did;
use gitlawb_core::identity::Keypair;
use std::sync::Arc;
use crate::config::Config;
use crate::db::Db;
+28 -13
View File
@@ -10,13 +10,13 @@
//! 3. If it exists → `git fetch --prune` from the origin.
//! 4. Mark done or failed.
use std::path::PathBuf;
use std::path::Path;
use std::sync::Arc;
use tracing::{info, warn};
use crate::db::Db;
use crate::config::Config;
use crate::db::Db;
/// Start the background sync worker. Returns immediately; the worker runs
/// as a detached tokio task.
@@ -76,7 +76,8 @@ async fn process_batch(db: &Db, config: &Config, machine_id: Option<&str>) {
continue;
}
};
let local_path = config.repos_dir
let local_path = config
.repos_dir
.join(owner_short)
.join(format!("{repo_name}.git"));
@@ -94,12 +95,14 @@ async fn process_batch(db: &Db, config: &Config, machine_id: Option<&str>) {
Ok(()) => {
info!(repo = %item.repo, origin = %origin_url, "synced repo from peer");
// Register in DB so git smart HTTP can serve the mirrored repo
let _ = db.upsert_mirror_repo(
owner_short,
repo_name,
local_path.to_str().unwrap_or(""),
machine_id,
).await;
let _ = db
.upsert_mirror_repo(
owner_short,
repo_name,
local_path.to_str().unwrap_or(""),
machine_id,
)
.await;
let _ = db.mark_sync_done(&item.id).await;
}
Err(e) => {
@@ -111,9 +114,14 @@ async fn process_batch(db: &Db, config: &Config, machine_id: Option<&str>) {
}
/// Mirror-clone a repo from a remote URL into a local bare repo.
async fn clone_repo(remote_url: &str, local_path: &PathBuf) -> anyhow::Result<()> {
async fn clone_repo(remote_url: &str, local_path: &Path) -> anyhow::Result<()> {
let out = tokio::process::Command::new("git")
.args(["clone", "--mirror", remote_url, local_path.to_str().unwrap_or(".")])
.args([
"clone",
"--mirror",
remote_url,
local_path.to_str().unwrap_or("."),
])
.output()
.await
.map_err(|e| anyhow::anyhow!("git clone failed to spawn: {e}"))?;
@@ -126,9 +134,16 @@ async fn clone_repo(remote_url: &str, local_path: &PathBuf) -> anyhow::Result<()
}
/// Fetch all refs from the remote into an existing mirror repo.
async fn fetch_repo(local_path: &PathBuf, remote_url: &str) -> anyhow::Result<()> {
async fn fetch_repo(local_path: &Path, remote_url: &str) -> anyhow::Result<()> {
let out = tokio::process::Command::new("git")
.args(["-C", local_path.to_str().unwrap_or("."), "fetch", "--prune", remote_url, "+refs/*:refs/*"])
.args([
"-C",
local_path.to_str().unwrap_or("."),
"fetch",
"--prune",
remote_url,
"+refs/*:refs/*",
])
.output()
.await
.map_err(|e| anyhow::anyhow!("git fetch failed to spawn: {e}"))?;
+2 -2
View File
@@ -24,8 +24,8 @@ type HmacSha256 = Hmac<Sha256>;
/// Compute `sha256=<hex>` HMAC signature for a webhook payload.
fn sign_payload(secret: &str, payload: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC accepts any key length");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
mac.update(payload);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
+50 -13
View File
@@ -36,7 +36,11 @@ pub enum AgentCmd {
pub async fn run(args: AgentArgs) -> Result<()> {
match args.cmd {
AgentCmd::List { node, capability, dir: _ } => cmd_list(node, capability).await,
AgentCmd::List {
node,
capability,
dir: _,
} => cmd_list(node, capability).await,
AgentCmd::Show { did, node } => cmd_show(did, node).await,
}
}
@@ -49,7 +53,10 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
None => "/api/v1/agents".to_string(),
};
let resp = client.get(&path).await.context("failed to connect to node")?;
let resp = client
.get(&path)
.await
.context("failed to connect to node")?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
@@ -59,7 +66,10 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
);
}
let body: Value = resp.json().await.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
let body: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
@@ -76,10 +86,20 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
println!("Agents on {} ({} total)\n", node, agents.len());
for agent in &agents {
let did = agent["did"].as_str().unwrap_or("?");
let short = did.split(':').next_back().map(|s| &s[..s.len().min(16)]).unwrap_or("?");
let short = did
.split(':')
.next_back()
.map(|s| &s[..s.len().min(16)])
.unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
let caps = agent["capabilities"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let model = agent["model"].as_str().unwrap_or("");
println!(" {short}… trust={trust:.2} {caps}");
@@ -106,7 +126,10 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
);
}
let agent: Value = resp.json().await.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
let agent: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = agent["message"].as_str().unwrap_or("unknown error");
@@ -115,8 +138,14 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
let full_did = agent["did"].as_str().unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
let caps = agent["capabilities"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let registered = agent["registered_at"].as_str().unwrap_or("?");
let model = agent["model"].as_str().unwrap_or("(none)");
@@ -172,7 +201,9 @@ mod tests {
.create_async()
.await;
cmd_list(server.url(), Some("git:push".to_string())).await.unwrap();
cmd_list(server.url(), Some("git:push".to_string()))
.await
.unwrap();
_m.assert_async().await;
}
@@ -217,7 +248,9 @@ mod tests {
.create_async()
.await;
cmd_show("did:key:z6MkTest".to_string(), server.url()).await.unwrap();
cmd_show("did:key:z6MkTest".to_string(), server.url())
.await
.unwrap();
}
#[tokio::test]
@@ -231,7 +264,9 @@ mod tests {
.create_async()
.await;
let err = cmd_show("did:key:z6MkMissing".to_string(), server.url()).await.unwrap_err();
let err = cmd_show("did:key:z6MkMissing".to_string(), server.url())
.await
.unwrap_err();
assert!(err.to_string().contains("agents API") || err.to_string().contains("not found"));
}
@@ -246,6 +281,8 @@ mod tests {
.create_async()
.await;
cmd_show("did:key:z6MkHigh".to_string(), server.url()).await.unwrap();
cmd_show("did:key:z6MkHigh".to_string(), server.url())
.await
.unwrap();
}
}
+102 -26
View File
@@ -111,14 +111,31 @@ pub enum BountyCmd {
pub async fn run(args: BountyArgs) -> Result<()> {
match args.cmd {
BountyCmd::Create { repo, title, amount, issue, tx_hash, deadline, node, dir } => {
cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await
}
BountyCmd::Create {
repo,
title,
amount,
issue,
tx_hash,
deadline,
node,
dir,
} => cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await,
BountyCmd::List { repo, status, node } => cmd_list(repo, status, node).await,
BountyCmd::Show { id, node } => cmd_show(id, node).await,
BountyCmd::Claim { id, wallet, node, dir } => cmd_claim(id, wallet, node, dir).await,
BountyCmd::Claim {
id,
wallet,
node,
dir,
} => cmd_claim(id, wallet, node, dir).await,
BountyCmd::Submit { id, pr, node, dir } => cmd_submit(id, pr, node, dir).await,
BountyCmd::Approve { id, tx_hash, node, dir } => cmd_approve(id, tx_hash, node, dir).await,
BountyCmd::Approve {
id,
tx_hash,
node,
dir,
} => cmd_approve(id, tx_hash, node, dir).await,
BountyCmd::Cancel { id, node, dir } => cmd_cancel(id, node, dir).await,
BountyCmd::Stats { node } => cmd_stats(node).await,
}
@@ -126,12 +143,19 @@ pub async fn run(args: BountyArgs) -> Result<()> {
#[allow(clippy::too_many_arguments)]
async fn cmd_create(
repo: String, title: String, amount: i64, issue: Option<String>,
tx_hash: Option<String>, deadline: Option<i64>, node: String, dir: Option<PathBuf>,
repo: String,
title: String,
amount: i64,
issue: Option<String>,
tx_hash: Option<String>,
deadline: Option<i64>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let (owner, name) = repo.split_once('/')
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let client = NodeClient::new(&node, Some(kp));
@@ -145,7 +169,10 @@ async fn cmd_create(
});
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/bounties"), &serde_json::to_vec(&body)?)
.post(
&format!("/api/v1/repos/{owner}/{name}/bounties"),
&serde_json::to_vec(&body)?,
)
.await
.context("failed to connect to node")?;
@@ -169,7 +196,8 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
let client = NodeClient::new(&node, None);
let url = if let Some(ref repo) = repo {
let (owner, name) = repo.split_once('/')
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let mut u = format!("/api/v1/repos/{owner}/{name}/bounties");
@@ -185,7 +213,10 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
u
};
let resp = client.get(&url).await.context("failed to connect to node")?;
let resp = client
.get(&url)
.await
.context("failed to connect to node")?;
let body: Value = resp.json().await.unwrap_or_default();
let bounties = body["bounties"].as_array();
@@ -222,14 +253,22 @@ async fn cmd_show(id: String, node: String) -> Result<()> {
Ok(())
}
async fn cmd_claim(id: String, wallet: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_claim(
id: String,
wallet: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::json!({ "wallet": wallet });
let resp = client
.post(&format!("/api/v1/bounties/{id}/claim"), &serde_json::to_vec(&body)?)
.post(
&format!("/api/v1/bounties/{id}/claim"),
&serde_json::to_vec(&body)?,
)
.await?;
let status = resp.status();
@@ -251,7 +290,10 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
let body = serde_json::json!({ "pr_id": pr });
let resp = client
.post(&format!("/api/v1/bounties/{id}/submit"), &serde_json::to_vec(&body)?)
.post(
&format!("/api/v1/bounties/{id}/submit"),
&serde_json::to_vec(&body)?,
)
.await?;
let status = resp.status();
@@ -266,14 +308,22 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
Ok(())
}
async fn cmd_approve(id: String, tx_hash: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_approve(
id: String,
tx_hash: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::json!({ "tx_hash": tx_hash });
let resp = client
.post(&format!("/api/v1/bounties/{id}/approve"), &serde_json::to_vec(&body)?)
.post(
&format!("/api/v1/bounties/{id}/approve"),
&serde_json::to_vec(&body)?,
)
.await?;
let status = resp.status();
@@ -295,7 +345,10 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
let body = serde_json::json!({});
let resp = client
.post(&format!("/api/v1/bounties/{id}/cancel"), &serde_json::to_vec(&body)?)
.post(
&format!("/api/v1/bounties/{id}/cancel"),
&serde_json::to_vec(&body)?,
)
.await?;
let status = resp.status();
@@ -332,7 +385,10 @@ async fn cmd_stats(node: String) -> Result<()> {
let count = entry["completed"].as_i64().unwrap_or(0);
let earned = entry["total_earned"].as_i64().unwrap_or(0);
let short = &did[did.len().saturating_sub(8)..];
println!(" {}. ...{short} {count} bounties {earned} $GITLAWB", i + 1);
println!(
" {}. ...{short} {count} bounties {earned} $GITLAWB",
i + 1
);
}
}
}
@@ -348,7 +404,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"/bounties$".to_string()))
@@ -362,10 +422,14 @@ mod tests {
"owner/repo".to_string(),
"Fix bug".to_string(),
50000,
None, None, None,
None,
None,
None,
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -375,10 +439,14 @@ mod tests {
"owner/repo".to_string(),
"Fix bug".to_string(),
50000,
None, None, None,
None,
None,
None,
"http://127.0.0.1:1".to_string(),
Some(dir.path().to_path_buf()),
).await.unwrap_err();
)
.await
.unwrap_err();
assert!(err.to_string().contains("identity not found"));
}
@@ -402,7 +470,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"/claim$".to_string()))
@@ -417,7 +489,9 @@ mod tests {
Some("0xWALLET".to_string()),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -432,7 +506,9 @@ mod tests {
.create_async()
.await;
let err = cmd_show("nonexistent".to_string(), server.url()).await.unwrap_err();
let err = cmd_show("nonexistent".to_string(), server.url())
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
+71 -18
View File
@@ -7,6 +7,7 @@ use clap::{Args, Subcommand};
use serde_json::Value;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct CertArgs {
@@ -41,16 +42,25 @@ pub async fn run(args: CertArgs) -> Result<()> {
}
}
/// Resolve "repo" into (owner, name) — if no slash, use the node's own DID short form.
/// Resolve "repo" into (owner, name) using the caller's DID when no slash is given.
async fn resolve_repo(repo: &str, node: &str) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
Ok((owner.to_string(), name.to_string()))
} else {
let client = NodeClient::new(node, None);
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
let short = did.split(':').next_back().unwrap_or(did).to_string();
let short = if let Ok(kp) = load_keypair_from_dir(None) {
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
Ok((short, repo.to_string()))
}
}
@@ -60,7 +70,11 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client.get(&path).await?.json().await
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to list certificates")?;
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
@@ -73,9 +87,9 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
println!("Ref certificates for {owner}/{name}");
println!();
for cert in &certs {
let id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
}
@@ -86,18 +100,23 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node).await?;
let client = NodeClient::new(&node, None);
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
// Fetch the certificate
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
let cert: Value = client.get(&path).await?.json().await
let resp = client
.get(&path)
.await?
.error_for_status()
.context("certificate not found")?;
let cert: Value = resp.json().await.context("certificate not found")?;
let cert_id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let old_sha = cert["old_sha"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let pusher = cert["pusher_did"].as_str().unwrap_or("?");
let node_did = cert["node_did"].as_str().unwrap_or("?");
let cert_id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let old_sha = cert["old_sha"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let pusher = cert["pusher_did"].as_str().unwrap_or("?");
let node_did = cert["node_did"].as_str().unwrap_or("?");
let signature = cert["signature"].as_str().unwrap_or("?");
let issued_at = cert["issued_at"].as_str().unwrap_or("?");
@@ -113,7 +132,11 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
// Reconstruct the signing payload and verify
// Fetch the node's current public key to verify
let info: Value = client.get("/").await?.json().await
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let current_node_did = info["did"].as_str().unwrap_or("");
@@ -139,3 +162,33 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
Ok(())
}
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
if id.len() >= 36 {
return Ok(id.to_string());
}
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client
.get(&path)
.await?
.error_for_status()
.context("failed to list certificates")?
.json()
.await
.context("failed to list certificates")?;
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
let matches: Vec<String> = certs
.iter()
.filter_map(|cert| cert["id"].as_str())
.filter(|cert_id| cert_id.starts_with(id))
.map(ToString::to_string)
.collect();
match matches.as_slice() {
[full_id] => Ok(full_id.to_string()),
[] => Ok(id.to_string()),
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
}
}
+40 -9
View File
@@ -42,7 +42,11 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(&args.node, None);
let info: Value = client.get("/").await?.json().await
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -51,8 +55,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
};
let client = NodeClient::new(&args.node, None);
let url = format!("/api/v1/repos/{owner}/{name}/changelog?limit={}", args.limit);
let resp = client.get(&url).await.context("failed to connect to node")?;
let url = format!(
"/api/v1/repos/{owner}/{name}/changelog?limit={}",
args.limit
);
let resp = client
.get(&url)
.await
.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
@@ -105,7 +115,9 @@ fn detect_repo_from_remote() -> Option<String> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() { return None; }
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
@@ -124,7 +136,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/changelog".to_string()))
@@ -148,7 +164,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let body = r#"{
"repo":"z/myrepo",
@@ -181,7 +201,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/changelog".to_string()))
@@ -206,10 +230,17 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/changelog\?limit=5".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"/changelog\?limit=5".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"z/myrepo","events":[],"count":0}"#)
+52 -12
View File
@@ -35,17 +35,36 @@ struct Check {
fix: Option<String>,
}
enum CheckState { Ok, Warn, Fail }
enum CheckState {
Ok,
Warn,
Fail,
}
impl Check {
fn pass(label: &'static str, detail: impl Into<String>) -> Self {
Self { label, state: CheckState::Ok, detail: detail.into(), fix: None }
Self {
label,
state: CheckState::Ok,
detail: detail.into(),
fix: None,
}
}
fn warn(label: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
Self { label, state: CheckState::Warn, detail: detail.into(), fix: Some(fix.into()) }
Self {
label,
state: CheckState::Warn,
detail: detail.into(),
fix: Some(fix.into()),
}
}
fn fail(label: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
Self { label, state: CheckState::Fail, detail: detail.into(), fix: Some(fix.into()) }
Self {
label,
state: CheckState::Fail,
detail: detail.into(),
fix: Some(fix.into()),
}
}
}
@@ -54,7 +73,9 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!();
let dir = args.dir.unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let mut checks = Vec::new();
@@ -97,7 +118,10 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
{
Some(v) => {
let node = v["node"].as_str().unwrap_or("unknown");
checks.push(Check::pass("registration", format!("registered with {node}")));
checks.push(Check::pass(
"registration",
format!("registered with {node}"),
));
}
None => {
checks.push(Check::fail(
@@ -123,7 +147,9 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
checks.push(Check::fail(
"GITLAWB_NODE",
format!("set to local address ({v}) — git push/clone will fail against remote nodes"),
format!(
"set to local address ({v}) — git push/clone will fail against remote nodes"
),
"export GITLAWB_NODE=https://node.gitlawb.com",
));
}
@@ -204,7 +230,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── Render ────────────────────────────────────────────────────────────
for check in &checks {
let icon = match check.state {
CheckState::Ok => "✓",
CheckState::Ok => "✓",
CheckState::Warn => "⚠",
CheckState::Fail => "✗",
};
@@ -216,7 +242,9 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!();
let has_issues = checks.iter().any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
let has_issues = checks
.iter()
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
if !has_issues {
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
} else {
@@ -245,7 +273,10 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
fn which_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| dir.join(name).exists())
std::env::split_paths(&paths).any(|dir| {
dir.join(name).exists()
|| (cfg!(target_os = "windows") && dir.join(format!("{name}.exe")).exists())
})
})
.unwrap_or(false)
}
@@ -277,7 +308,12 @@ async fn check_version(current: &'static str) -> Check {
let tag = match body["tag_name"].as_str() {
Some(t) => t.trim_start_matches('v'),
None => return Check::pass("version", format!("v{current} (could not parse latest tag)")),
None => {
return Check::pass(
"version",
format!("v{current} (could not parse latest tag)"),
)
}
};
if is_newer(tag, current) {
@@ -295,7 +331,11 @@ async fn check_version(current: &'static str) -> Check {
fn is_newer(latest: &str, current: &str) -> bool {
fn parse(v: &str) -> (u32, u32, u32) {
let mut parts = v.splitn(3, '.').map(|p| p.parse::<u32>().unwrap_or(0));
(parts.next().unwrap_or(0), parts.next().unwrap_or(0), parts.next().unwrap_or(0))
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
parse(latest) > parse(current)
}
+16 -5
View File
@@ -17,19 +17,28 @@ impl NodeClient {
.user_agent("gl/0.2.0 gitlawb-cli")
.build()
.expect("failed to build HTTP client");
Self { inner, node_url: node_url.into(), keypair }
Self {
inner,
node_url: node_url.into(),
keypair,
}
}
/// GET request — no auth (public read endpoints).
pub async fn get(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
self.inner.get(&url).send().await.with_context(|| format!("GET {url}"))
self.inner
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))
}
/// POST with JSON body + RFC 9421 HTTP Signature auth.
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self.inner
let mut req = self
.inner
.post(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
@@ -48,7 +57,8 @@ impl NodeClient {
/// PUT with RFC 9421 HTTP Signature auth (idempotent write).
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self.inner
let mut req = self
.inner
.put(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
@@ -67,7 +77,8 @@ impl NodeClient {
/// DELETE with RFC 9421 HTTP Signature auth.
pub async fn delete(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self.inner
let mut req = self
.inner
.delete(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
+100 -29
View File
@@ -1,7 +1,7 @@
use anyhow::{Context, Result};
use clap::Subcommand;
use gitlawb_core::identity::Keypair;
use gitlawb_core::did::DidDocument;
use gitlawb_core::identity::Keypair;
use std::fs;
use std::path::{Path, PathBuf};
@@ -90,8 +90,12 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
.join(".gitlawb")
};
let path = key_path(&base);
let pem = fs::read_to_string(&path)
.with_context(|| format!("no identity found at {}\nRun `gl identity new` to create one", path.display()))?;
let pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
path.display()
)
})?;
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
}
@@ -182,13 +186,21 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let pem = fs::read_to_string(&src)
.with_context(|| format!("no identity found at {} — run `gl identity new` first", src.display()))?;
let pem = fs::read_to_string(&src).with_context(|| {
format!(
"no identity found at {} — run `gl identity new` first",
src.display()
)
})?;
// Verify it loads before copying
let keypair = Keypair::from_pem(&pem).context("identity.pem is corrupted")?;
let dest = out.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("identity.pem.bak"));
let dest = out.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.join("identity.pem.bak")
});
#[cfg(unix)]
{
@@ -279,18 +291,24 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_creates_pem() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
assert!(dir.path().join("identity.pem").exists());
}
#[tokio::test]
async fn test_cmd_new_force_overwrites_on_confirm() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
// Simulate user typing "y" at the --force prompt
let mut reader = std::io::Cursor::new(b"y\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader).await.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader)
.await
.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_ne!(pem1, pem2);
}
@@ -298,11 +316,15 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_force_aborts_on_n() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
// Simulate user typing "n" — should abort even with --force
let mut reader = std::io::Cursor::new(b"n\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader).await.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader)
.await
.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_eq!(pem1, pem2);
}
@@ -310,10 +332,14 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_no_force_aborts_on_n() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
let mut reader = std::io::Cursor::new(b"n\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), false, &mut reader).await.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), false, &mut reader)
.await
.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_eq!(pem1, pem2);
}
@@ -321,22 +347,30 @@ mod tests {
#[tokio::test]
async fn test_cmd_show_succeeds() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_show(Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
async fn test_cmd_export_produces_did_document() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_export(Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
async fn test_cmd_sign_succeeds() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_sign("hello gitlawb".to_string(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_sign("hello gitlawb".to_string(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[test]
@@ -351,7 +385,9 @@ mod tests {
#[tokio::test]
async fn test_pem_roundtrip() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
// Loading the keypair back should succeed and produce a valid DID
let kp = load_keypair_from_dir(Some(dir.path())).unwrap();
let did = kp.did().to_string();
@@ -364,12 +400,21 @@ mod tests {
let dst_dir = TempDir::new().unwrap();
// Create an identity and back it up
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
let backup_path = src_dir.path().join("identity.pem.bak");
cmd_backup(Some(backup_path.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
cmd_backup(
Some(backup_path.clone()),
Some(src_dir.path().to_path_buf()),
)
.await
.unwrap();
// Restore to a fresh directory
cmd_restore(backup_path, Some(dst_dir.path().to_path_buf()), false).await.unwrap();
cmd_restore(backup_path, Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
// The restored DID should match the original
let orig = load_keypair_from_dir(Some(src_dir.path())).unwrap();
@@ -403,15 +448,28 @@ mod tests {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
let backup = src_dir.path().join("identity.pem.bak");
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf()))
.await
.unwrap();
// Simulate user typing "y" at the --force prompt
let mut reader = std::io::Cursor::new(b"y\n");
cmd_restore_with_reader(backup, Some(dst_dir.path().to_path_buf()), true, &mut reader).await.unwrap();
cmd_restore_with_reader(
backup,
Some(dst_dir.path().to_path_buf()),
true,
&mut reader,
)
.await
.unwrap();
let src_kp = load_keypair_from_dir(Some(src_dir.path())).unwrap();
let dst_kp = load_keypair_from_dir(Some(dst_dir.path())).unwrap();
@@ -423,16 +481,29 @@ mod tests {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
let original_did = load_keypair_from_dir(Some(dst_dir.path())).unwrap().did();
let backup = src_dir.path().join("identity.pem.bak");
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf()))
.await
.unwrap();
// Simulate user typing "n" — should abort
let mut reader = std::io::Cursor::new(b"n\n");
cmd_restore_with_reader(backup, Some(dst_dir.path().to_path_buf()), true, &mut reader).await.unwrap();
cmd_restore_with_reader(
backup,
Some(dst_dir.path().to_path_buf()),
true,
&mut reader,
)
.await
.unwrap();
let dst_kp = load_keypair_from_dir(Some(dst_dir.path())).unwrap();
assert_eq!(original_did, dst_kp.did());
+48 -18
View File
@@ -73,7 +73,9 @@ pub async fn run(args: InitArgs) -> Result<()> {
"did": did.to_string(),
"capabilities": ["git:push", "git:fetch", "issue:create", "pr:open"],
}))?;
let resp = client.post("/api/register", &body).await
let resp = client
.post("/api/register", &body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON from register")?;
@@ -89,9 +91,10 @@ pub async fn run(args: InitArgs) -> Result<()> {
// Save UCAN if returned
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
if !ucan.is_empty() {
let ucan_dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir().unwrap_or_default().join(".gitlawb")
});
let ucan_dir = args
.dir
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
std::fs::create_dir_all(&ucan_dir)?;
let record = json!({
"ucan": ucan,
@@ -99,7 +102,10 @@ pub async fn run(args: InitArgs) -> Result<()> {
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(ucan_dir.join("ucan.json"), serde_json::to_string_pretty(&record)?)?;
std::fs::write(
ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?,
)?;
}
}
println!(" Agent registered.");
@@ -118,7 +124,9 @@ pub async fn run(args: InitArgs) -> Result<()> {
"description": args.description,
"is_public": true,
}))?;
let resp = client.post("/api/v1/repos", &body).await
let resp = client
.post("/api/v1/repos", &body)
.await
.context("failed to create repo")?;
let repo_status = resp.status();
let repo_result: Value = resp.json().await.context("invalid JSON from create repo")?;
@@ -199,7 +207,11 @@ mod tests {
fn write_identity(dir: &TempDir) -> gitlawb_core::identity::Keypair {
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
kp
}
@@ -230,30 +242,45 @@ mod tests {
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Welcome","ucan":"test.token","trust_score":0.5}"#)
.create_async().await;
.create_async()
.await;
let _repo = server
.mock("POST", "/api/v1/repos")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"r1","name":"test-repo"}"#)
.create_async().await;
.create_async()
.await;
// We can't fully test gl init because it uses std::env::current_dir()
// but we can test the individual steps
let client = NodeClient::new(&server.url(), Some(kp.clone()));
let client = NodeClient::new(server.url(), Some(kp.clone()));
// Register
let body = serde_json::to_vec(&json!({
"did": kp.did().to_string(),
"capabilities": ["git:push"],
})).unwrap();
let resp: Value = client.post("/api/register", &body).await.unwrap().json().await.unwrap();
}))
.unwrap();
let resp: Value = client
.post("/api/register", &body)
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["message"], "Welcome");
// Create repo
let body = serde_json::to_vec(&json!({"name": "test-repo", "is_public": true})).unwrap();
let resp: Value = client.post("/api/v1/repos", &body).await.unwrap().json().await.unwrap();
let resp: Value = client
.post("/api/v1/repos", &body)
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["name"], "test-repo");
}
@@ -268,13 +295,15 @@ mod tests {
.with_status(409)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"already registered"}"#)
.create_async().await;
.create_async()
.await;
let client = NodeClient::new(&server.url(), Some(kp.clone()));
let client = NodeClient::new(server.url(), Some(kp.clone()));
let body = serde_json::to_vec(&json!({
"did": kp.did().to_string(),
"capabilities": ["git:push"],
})).unwrap();
}))
.unwrap();
let resp = client.post("/api/register", &body).await.unwrap();
let status = resp.status();
let payload: Value = resp.json().await.unwrap();
@@ -295,9 +324,10 @@ mod tests {
.with_status(409)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository already exists"}"#)
.create_async().await;
.create_async()
.await;
let client = NodeClient::new(&server.url(), Some(kp.clone()));
let client = NodeClient::new(server.url(), Some(kp.clone()));
let body = serde_json::to_vec(&json!({"name": "existing", "is_public": true})).unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
let status = resp.status();
+8 -2
View File
@@ -63,7 +63,11 @@ async fn cmd_list(node: String) -> Result<()> {
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
// Trim pinned_at to date+time without subseconds
let ts = if pinned_at.len() >= 19 { &pinned_at[..19] } else { pinned_at };
let ts = if pinned_at.len() >= 19 {
&pinned_at[..19]
} else {
pinned_at
};
println!(" {cid}");
println!(" sha256: {sha}");
println!(" pinned: {ts}");
@@ -98,7 +102,9 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
// Write raw bytes to stdout (allows piping to files or other tools)
let bytes = resp.bytes().await.context("failed to read response body")?;
use std::io::Write;
std::io::stdout().write_all(&bytes).context("failed to write to stdout")?;
std::io::stdout()
.write_all(&bytes)
.context("failed to write to stdout")?;
Ok(())
}
+142 -40
View File
@@ -1,10 +1,10 @@
//! `gl issue` — issue management commands.
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;
use chrono::Utc;
use uuid::Uuid;
use crate::http::NodeClient;
@@ -88,19 +88,48 @@ pub enum IssueCmd {
pub async fn run(args: IssueArgs) -> Result<()> {
match args.cmd {
IssueCmd::Create { repo, title, body, node, dir } => {
cmd_create(repo, title, body, node, dir).await
}
IssueCmd::Create {
repo,
title,
body,
node,
dir,
} => cmd_create(repo, title, body, node, dir).await,
IssueCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
IssueCmd::Show { repo, id, node, dir } => cmd_show(repo, id, node, dir).await,
IssueCmd::Close { repo, id, node, dir } => cmd_close(repo, id, node, dir).await,
IssueCmd::Comment { repo, id, body, node, dir } => cmd_issue_comment(repo, id, body, node, dir).await,
IssueCmd::Comments { repo, id, node, dir } => cmd_issue_comments(repo, id, node, dir).await,
IssueCmd::Show {
repo,
id,
node,
dir,
} => cmd_show(repo, id, node, dir).await,
IssueCmd::Close {
repo,
id,
node,
dir,
} => cmd_close(repo, id, node, dir).await,
IssueCmd::Comment {
repo,
id,
body,
node,
dir,
} => cmd_issue_comment(repo, id, body, node, dir).await,
IssueCmd::Comments {
repo,
id,
node,
dir,
} => cmd_issue_comments(repo, id, node, dir).await,
}
}
/// Resolve "repo" into (owner, name) using the caller's keypair DID when no slash given.
async fn resolve_repo(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
async fn resolve_repo(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
Ok((owner.to_string(), name.to_string()))
} else {
@@ -109,7 +138,11 @@ async fn resolve_repo(repo: &str, node: &str, dir: Option<&std::path::Path>) ->
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client.get("/").await?.json().await
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -157,7 +190,9 @@ async fn cmd_create(
let client = NodeClient::new(&node, Some(keypair));
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp = client.post(&path, &request_body).await
let resp = client
.post(&path, &request_body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -182,7 +217,11 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp: Value = client.get(&path).await?.json().await
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to list issues")?;
let issues = resp["issues"].as_array().cloned().unwrap_or_default();
@@ -198,8 +237,15 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let id = issue["id"].as_str().unwrap_or("?");
let title = issue["title"].as_str().unwrap_or("(no title)");
let status = issue["status"].as_str().unwrap_or("?");
let created = issue["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let icon = match status { "open" => "○", "closed" => "✗", _ => "?" };
let created = issue["created_at"]
.as_str()
.map(|s| &s[..10])
.unwrap_or("?");
let icon = match status {
"open" => "○",
"closed" => "✗",
_ => "?",
};
println!(" {icon} {id:.8} {created} {title}");
}
Ok(())
@@ -210,7 +256,10 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
let resp = client.get(&path).await.context("failed to connect to node")?;
let resp = client
.get(&path)
.await
.context("failed to connect to node")?;
let status = resp.status();
let issue: Value = resp.json().await.context("invalid JSON response")?;
@@ -244,7 +293,9 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
let body = serde_json::to_vec(&json!({ "status": "closed" }))?;
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
let resp = client.post(&format!("{path}/close"), &body).await
let resp = client
.post(&format!("{path}/close"), &body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -259,15 +310,23 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
}
async fn cmd_issue_comment(
repo: String, id: String, body: String,
node: String, dir: Option<PathBuf>,
repo: String,
id: String,
body: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let payload = serde_json::to_vec(&serde_json::json!({ "body": body }))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments"), &payload).await
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments"),
&payload,
)
.await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -281,12 +340,23 @@ async fn cmd_issue_comment(
Ok(())
}
async fn cmd_issue_comments(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_issue_comments(
repo: String,
id: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -297,7 +367,11 @@ async fn cmd_issue_comments(repo: String, id: String, node: String, dir: Option<
println!("Comments on issue {id} ({} total)\n", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -335,9 +409,13 @@ mod tests {
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -357,9 +435,13 @@ mod tests {
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -458,7 +540,9 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/missing-id$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/missing-id$".to_string(),
),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -487,7 +571,9 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/abc-123/close$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/abc-123/close$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -515,7 +601,9 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/close$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/close$".to_string(),
),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -571,7 +659,9 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/comments$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/comments$".to_string(),
),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -600,7 +690,9 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/abc123/comments$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/abc123/comments$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -608,9 +700,14 @@ mod tests {
.create_async()
.await;
cmd_issue_comments("myrepo".to_string(), "abc123".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_issue_comments(
"myrepo".to_string(),
"abc123".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -630,8 +727,13 @@ mod tests {
.create_async()
.await;
cmd_issue_comments("myrepo".to_string(), "abc123".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_issue_comments(
"myrepo".to_string(),
"abc123".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
}
+1 -1
View File
@@ -9,13 +9,13 @@ mod cert;
mod changelog;
mod doctor;
mod http;
mod name;
mod identity;
mod init;
mod ipfs_cmd;
mod issue;
mod mcp;
mod mirror;
mod name;
mod node;
mod node_stake;
mod peer;
+322 -124
View File
@@ -89,7 +89,10 @@ async fn serve(node: String, dir: Option<PathBuf>) -> Result<()> {
}
};
tracing::debug!("← {}", msg.get("method").and_then(|v| v.as_str()).unwrap_or("?"));
tracing::debug!(
"← {}",
msg.get("method").and_then(|v| v.as_str()).unwrap_or("?")
);
let response = handle(&msg, &node, dir.as_deref()).await;
@@ -104,10 +107,7 @@ async fn serve(node: String, dir: Option<PathBuf>) -> Result<()> {
async fn handle(msg: &Value, node: &str, dir: Option<&std::path::Path>) -> Value {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("");
let method = msg.get("method").and_then(|v| v.as_str()).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let result = dispatch(method, params, node, dir).await;
@@ -688,8 +688,10 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let repo: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&repo)?)
}
@@ -697,8 +699,10 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let commits: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/commits")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{name}/commits"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&commits)?)
}
@@ -707,8 +711,10 @@ async fn call_tool(
let path = args["path"].as_str().unwrap_or("");
let owner = resolve_owner(&args, &client).await?;
let tree: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/tree/{path}")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{name}/tree/{path}"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&tree)?)
}
@@ -735,12 +741,21 @@ async fn call_tool(
}
"agent_capabilities" => Ok(serde_json::to_string_pretty(&json!([
"git:push", "git:fetch", "git:admin",
"pr:open", "pr:merge", "pr:review",
"issue:create", "issue:close",
"network:join", "network:gossip",
"agent:deploy", "agent:invoke",
"repo:admin", "repo:read", "repo:write"
"git:push",
"git:fetch",
"git:admin",
"pr:open",
"pr:merge",
"pr:review",
"issue:create",
"issue:close",
"network:join",
"network:gossip",
"agent:deploy",
"agent:invoke",
"repo:admin",
"repo:read",
"repo:write"
]))?),
"ucan_show" => {
@@ -774,7 +789,10 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let resp = client
.get(&format!("/{owner}/{name}/info/refs?service=git-upload-pack")).await?;
.get(&format!(
"/{owner}/{name}/info/refs?service=git-upload-pack"
))
.await?;
let bytes = resp.bytes().await?;
// Parse pkt-line refs
let refs = parse_info_refs(&bytes);
@@ -782,7 +800,9 @@ async fn call_tool(
}
"pr_create" => {
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let head = args["head"].as_str().context("missing 'head'")?;
let base = args["base"].as_str().unwrap_or("main");
@@ -795,8 +815,10 @@ async fn call_tool(
"target_branch": base,
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &body).await?
.json().await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -804,8 +826,10 @@ async fn call_tool(
let repo = args["repo"].as_str().context("missing 'repo'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -814,12 +838,20 @@ async fn call_tool(
let number = args["number"].as_i64().context("missing 'number'")?;
let owner = resolve_owner(&args, &client).await?;
let pr: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?
.json()
.await?;
let reviews: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&json!({ "pr": pr, "reviews": reviews["reviews"] }))?)
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(
&json!({ "pr": pr, "reviews": reviews["reviews"] }),
)?)
}
"pr_diff" => {
@@ -827,14 +859,18 @@ async fn call_tool(
let number = args["number"].as_i64().context("missing 'number'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?
.json()
.await?;
let diff = resp["diff"].as_str().unwrap_or("(empty diff)");
Ok(diff.to_string())
}
"pr_review" => {
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let number = args["number"].as_i64().context("missing 'number'")?;
let status = args["status"].as_str().context("missing 'status'")?;
@@ -844,8 +880,13 @@ async fn call_tool(
"body": args["body"],
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"), &body).await?
.json().await?;
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"),
&body,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -855,16 +896,24 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"), &body).await?
.json().await?;
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"),
&body,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"webhook_create" => {
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let url = args["url"].as_str().context("missing 'url'")?;
let events = args["events"].as_array()
let events = args["events"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_else(|| vec!["*"]);
let owner = resolve_owner(&args, &client).await?;
@@ -874,8 +923,10 @@ async fn call_tool(
"events": events,
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body).await?
.json().await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -883,8 +934,10 @@ async fn call_tool(
let repo = args["repo"].as_str().context("missing 'repo'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -894,13 +947,14 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp: Value = client
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body).await?
.json().await?;
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
// ── Bounty tools ────────────────────────────────────────────────────
"bounty_list" => {
let url = if let Some(repo) = args.get("repo").and_then(|v| v.as_str()) {
let (owner, name) = repo.split_once('/').context("repo must be owner/name")?;
@@ -922,7 +976,11 @@ async fn call_tool(
"bounty_show" => {
let id = args["id"].as_str().context("missing 'id'")?;
let resp: Value = client.get(&format!("/api/v1/bounties/{id}")).await?.json().await?;
let resp: Value = client
.get(&format!("/api/v1/bounties/{id}"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -936,8 +994,13 @@ async fn call_tool(
"tx_hash": args.get("tx_hash").and_then(|v| v.as_str()),
});
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{name}/bounties"), &serde_json::to_vec(&body)?)
.await?.json().await?;
.post(
&format!("/api/v1/repos/{owner}/{name}/bounties"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -945,8 +1008,13 @@ async fn call_tool(
let id = args["id"].as_str().context("missing 'id'")?;
let body = json!({ "wallet": args.get("wallet").and_then(|v| v.as_str()) });
let resp: Value = client
.post(&format!("/api/v1/bounties/{id}/claim"), &serde_json::to_vec(&body)?)
.await?.json().await?;
.post(
&format!("/api/v1/bounties/{id}/claim"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -955,8 +1023,13 @@ async fn call_tool(
let pr_id = args["pr_id"].as_str().context("missing 'pr_id'")?;
let body = json!({ "pr_id": pr_id });
let resp: Value = client
.post(&format!("/api/v1/bounties/{id}/submit"), &serde_json::to_vec(&body)?)
.await?.json().await?;
.post(
&format!("/api/v1/bounties/{id}/submit"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -966,7 +1039,6 @@ async fn call_tool(
}
// ── Task tools ────────────────────────────────────────────────────
"task_list" => {
let limit = args["limit"].as_i64().unwrap_or(50);
let mut path = format!("/api/v1/tasks?limit={limit}");
@@ -1003,8 +1075,10 @@ async fn call_tool(
let id = args["id"].as_str().context("missing 'id'")?;
let body = serde_json::to_vec(&json!({ "assignee_did": assignee_did }))?;
let resp: Value = client
.post(&format!("/api/v1/tasks/{id}/claim"), &body).await?
.json().await?;
.post(&format!("/api/v1/tasks/{id}/claim"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1017,13 +1091,14 @@ async fn call_tool(
"by_did": by_did,
}))?;
let resp: Value = client
.post(&format!("/api/v1/tasks/{id}/complete"), &body).await?
.json().await?;
.post(&format!("/api/v1/tasks/{id}/complete"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
// ── UCAN delegation tools ─────────────────────────────────────────
"ucan_delegate" => {
let kp = keypair.context("no identity found — run `gl identity new` first")?;
let to_str = args["to"].as_str().context("missing 'to'")?;
@@ -1034,7 +1109,8 @@ async fn call_tool(
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
let exp = args.get("expiry_hours")
let exp = args
.get("expiry_hours")
.and_then(|v| v.as_i64())
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
@@ -1057,11 +1133,14 @@ async fn call_tool(
"ucan_verify" => {
let token = args["token"].as_str().context("missing 'token'")?;
let ucan = gitlawb_core::ucan::Ucan::decode(token)
.context("failed to parse UCAN token")?;
let ucan =
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
let sig_valid = ucan.verify_signature().is_ok();
let expired = ucan.is_expired();
let caps: Vec<Value> = ucan.payload.att.iter()
let caps: Vec<Value> = ucan
.payload
.att
.iter()
.map(|c| json!({ "with": c.with, "can": c.can }))
.collect();
@@ -1077,7 +1156,6 @@ async fn call_tool(
}
// ── Issue tools ───────────────────────────────────────────────────
"issue_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
@@ -1087,13 +1165,17 @@ async fn call_tool(
(owner, repo.to_string())
};
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/issues")).await?
.json().await?;
.get(&format!("/api/v1/repos/{owner}/{name}/issues"))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"issue_create" => {
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let title = args["title"].as_str().context("missing 'title'")?;
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
@@ -1107,13 +1189,17 @@ async fn call_tool(
"body": args.get("body").and_then(|v| v.as_str()),
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{name}/issues"), &body).await?
.json().await?;
.post(&format!("/api/v1/repos/{owner}/{name}/issues"), &body)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"issue_comment" => {
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let issue_id = args["issue_id"].as_str().context("missing 'issue_id'")?;
let comment_body = args["body"].as_str().context("missing 'body'")?;
@@ -1125,8 +1211,13 @@ async fn call_tool(
};
let body = serde_json::to_vec(&json!({ "body": comment_body }))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{name}/issues/{issue_id}/comments"), &body).await?
.json().await?;
.post(
&format!("/api/v1/repos/{owner}/{name}/issues/{issue_id}/comments"),
&body,
)
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1166,10 +1257,19 @@ fn parse_info_refs(bytes: &[u8]) -> Value {
}
while pos + 4 <= bytes.len() {
let Ok(hex) = std::str::from_utf8(&bytes[pos..pos + 4]) else { break };
let Ok(len) = usize::from_str_radix(hex, 16) else { break };
if len == 0 { pos += 4; continue; }
if len < 4 || pos + len > bytes.len() { break; }
let Ok(hex) = std::str::from_utf8(&bytes[pos..pos + 4]) else {
break;
};
let Ok(len) = usize::from_str_radix(hex, 16) else {
break;
};
if len == 0 {
pos += 4;
continue;
}
if len < 4 || pos + len > bytes.len() {
break;
}
let line = std::str::from_utf8(&bytes[pos + 4..pos + len]).unwrap_or("");
let line = line.trim_end_matches('\n');
@@ -1256,7 +1356,11 @@ mod tests {
assert!(output.contains("\r\n\r\n"));
// Verify the content-length value matches the JSON body
let parts: Vec<&str> = output.splitn(2, "\r\n\r\n").collect();
let len: usize = parts[0].strip_prefix("Content-Length: ").unwrap().parse().unwrap();
let len: usize = parts[0]
.strip_prefix("Content-Length: ")
.unwrap()
.parse()
.unwrap();
assert_eq!(len, parts[1].len());
}
@@ -1301,9 +1405,14 @@ mod tests {
#[tokio::test]
async fn test_dispatch_notifications_initialized() {
let result = dispatch("notifications/initialized", json!({}), "http://localhost", None)
.await
.unwrap();
let result = dispatch(
"notifications/initialized",
json!({}),
"http://localhost",
None,
)
.await
.unwrap();
assert_eq!(result, Value::Null);
}
@@ -1323,10 +1432,7 @@ mod tests {
assert!(!tools.is_empty());
// Verify expected tools exist
let tool_names: Vec<&str> = tools
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(tool_names.contains(&"identity_show"));
assert!(tool_names.contains(&"repo_create"));
assert!(tool_names.contains(&"pr_create"));
@@ -1390,8 +1496,14 @@ mod tests {
let tools = tools.as_array().unwrap();
for tool in tools {
assert!(tool.get("name").is_some(), "tool missing name: {tool}");
assert!(tool.get("description").is_some(), "tool missing description: {tool}");
assert!(tool.get("inputSchema").is_some(), "tool missing inputSchema: {tool}");
assert!(
tool.get("description").is_some(),
"tool missing description: {tool}"
);
assert!(
tool.get("inputSchema").is_some(),
"tool missing inputSchema: {tool}"
);
assert_eq!(tool["inputSchema"]["type"], "object");
}
}
@@ -1409,8 +1521,12 @@ mod tests {
#[test]
fn test_tool_definitions_include_task_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(names.contains(&"task_list"));
assert!(names.contains(&"task_create"));
assert!(names.contains(&"task_claim"));
@@ -1420,8 +1536,12 @@ mod tests {
#[test]
fn test_tool_definitions_include_ucan_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(names.contains(&"ucan_delegate"));
assert!(names.contains(&"ucan_verify"));
}
@@ -1429,8 +1549,12 @@ mod tests {
#[test]
fn test_tool_definitions_include_issue_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(names.contains(&"issue_list"));
assert!(names.contains(&"issue_create"));
assert!(names.contains(&"issue_comment"));
@@ -1440,14 +1564,24 @@ mod tests {
async fn test_task_list_via_mcp() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}]}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool("task_list", json!({"status": "pending"}), &server.url(), None)
.await.unwrap();
let result = call_tool(
"task_list",
json!({"status": "pending"}),
&server.url(),
None,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["tasks"][0]["id"], "t1");
}
@@ -1457,21 +1591,28 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t2","kind":"code-review","status":"pending"}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"task_create",
json!({"kind": "code-review"}),
&server.url(),
Some(dir.path()),
).await.unwrap();
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["kind"], "code-review");
}
@@ -1481,18 +1622,28 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/t3/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t3","status":"claimed"}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"task_claim", json!({"id": "t3"}), &server.url(), Some(dir.path()),
).await.unwrap();
"task_claim",
json!({"id": "t3"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["status"], "claimed");
}
@@ -1502,19 +1653,28 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/t4/complete")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t4","status":"completed"}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"task_complete", json!({"id": "t4", "result": "all good"}),
&server.url(), Some(dir.path()),
).await.unwrap();
"task_complete",
json!({"id": "t4", "result": "all good"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["status"], "completed");
}
@@ -1523,7 +1683,11 @@ mod tests {
async fn test_ucan_delegate_via_mcp() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
let result = call_tool(
@@ -1536,7 +1700,9 @@ mod tests {
}),
"http://localhost",
Some(dir.path()),
).await.unwrap();
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["audience"], audience.did().to_string());
@@ -1549,15 +1715,25 @@ mod tests {
let kp = gitlawb_core::identity::Keypair::generate();
let audience = gitlawb_core::identity::Keypair::generate();
let ucan = gitlawb_core::ucan::Ucan::issue(
&kp, audience.did(),
vec![gitlawb_core::ucan::Capability::new("gitlawb://test", "git/push")],
&kp,
audience.did(),
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://test",
"git/push",
)],
None,
).unwrap();
)
.unwrap();
let token = ucan.encode().unwrap();
let result = call_tool(
"ucan_verify", json!({"token": token}), "http://localhost", None,
).await.unwrap();
"ucan_verify",
json!({"token": token}),
"http://localhost",
None,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["valid"], true);
assert_eq!(parsed["signature_valid"], true);
@@ -1572,11 +1748,17 @@ mod tests {
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"issues":[{"id":"i1","title":"Bug","status":"open"}]}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"issue_list", json!({"repo": "alice/myrepo"}), &server.url(), None,
).await.unwrap();
"issue_list",
json!({"repo": "alice/myrepo"}),
&server.url(),
None,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["issues"][0]["id"], "i1");
}
@@ -1586,20 +1768,28 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/repos/alice/myrepo/issues")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"i2","title":"New bug"}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"issue_create",
json!({"repo": "alice/myrepo", "title": "New bug", "body": "steps to reproduce"}),
&server.url(), Some(dir.path()),
).await.unwrap();
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["title"], "New bug");
}
@@ -1609,20 +1799,28 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/repos/alice/myrepo/issues/i1/comments")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"c1","body":"looks good"}"#)
.create_async().await;
.create_async()
.await;
let result = call_tool(
"issue_comment",
json!({"repo": "alice/myrepo", "issue_id": "i1", "body": "looks good"}),
&server.url(), Some(dir.path()),
).await.unwrap();
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["body"], "looks good");
}
+23 -7
View File
@@ -43,12 +43,16 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
// ── 1. Derive repo name ───────────────────────────────────────────────
let name = match args.repo {
Some(n) => n,
None => extract_repo_name(&source)
.with_context(|| format!("could not derive repo name from '{source}' — use --repo <name>"))?,
None => extract_repo_name(&source).with_context(|| {
format!("could not derive repo name from '{source}' — use --repo <name>")
})?,
};
// Validate: same rules as `gl repo create`
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
bail!("repo name '{name}' must contain only alphanumeric characters, hyphens, and underscores\nUse --repo to provide a valid name");
}
@@ -68,7 +72,11 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
let mirror_path = tmp_root.join(&name);
// RAII guard: removes the temp dir when this binding is dropped (success or failure).
struct TmpGuard(PathBuf);
impl Drop for TmpGuard { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } }
impl Drop for TmpGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = TmpGuard(tmp_root);
println!("Cloning source (this may take a while for large repos)...");
@@ -218,7 +226,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/repos")
@@ -232,7 +244,9 @@ mod tests {
// API error path by calling the create step directly via NodeClient.
let kp2 = gitlawb_core::identity::Keypair::generate();
let client = NodeClient::new(server.url(), Some(kp2));
let body = serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"})).unwrap();
let body =
serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"}))
.unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
assert_eq!(resp.status(), 409);
let payload: Value = resp.json().await.unwrap();
@@ -253,7 +267,9 @@ mod tests {
let kp = gitlawb_core::identity::Keypair::generate();
let client = NodeClient::new(server.url(), Some(kp));
let body = serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"})).unwrap();
let body =
serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"}))
.unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
assert!(resp.status().is_success());
let payload: Value = resp.json().await.unwrap();
+59 -34
View File
@@ -9,11 +9,8 @@
//! gl name resolve-did <did> — resolve DID from on-chain registry
use alloy::{
network::EthereumWallet,
primitives::Address,
providers::ProviderBuilder,
signers::local::PrivateKeySigner,
sol,
network::EthereumWallet, primitives::Address, providers::ProviderBuilder,
signers::local::PrivateKeySigner, sol,
};
use anyhow::{Context, Result};
use clap::Subcommand;
@@ -133,18 +130,39 @@ pub enum NameCmd {
pub async fn run(args: NameArgs) -> Result<()> {
match args.cmd {
NameCmd::Register { name, private_key, rpc_url, contract, dir } =>
cmd_register(name, private_key, rpc_url, contract, dir).await,
NameCmd::Resolve { name, rpc_url, contract } =>
cmd_resolve(name, rpc_url, contract).await,
NameCmd::Lookup { did, rpc_url, contract } =>
cmd_lookup(did, rpc_url, contract).await,
NameCmd::Available { name, rpc_url, contract } =>
cmd_available(name, rpc_url, contract).await,
NameCmd::RegisterDid { private_key, rpc_url, contract, dir } =>
cmd_register_did(private_key, rpc_url, contract, dir).await,
NameCmd::ResolveDid { did, rpc_url, contract } =>
cmd_resolve_did(did, rpc_url, contract).await,
NameCmd::Register {
name,
private_key,
rpc_url,
contract,
dir,
} => cmd_register(name, private_key, rpc_url, contract, dir).await,
NameCmd::Resolve {
name,
rpc_url,
contract,
} => cmd_resolve(name, rpc_url, contract).await,
NameCmd::Lookup {
did,
rpc_url,
contract,
} => cmd_lookup(did, rpc_url, contract).await,
NameCmd::Available {
name,
rpc_url,
contract,
} => cmd_available(name, rpc_url, contract).await,
NameCmd::RegisterDid {
private_key,
rpc_url,
contract,
dir,
} => cmd_register_did(private_key, rpc_url, contract, dir).await,
NameCmd::ResolveDid {
did,
rpc_url,
contract,
} => cmd_resolve_did(did, rpc_url, contract).await,
}
}
@@ -152,25 +170,35 @@ pub async fn run(args: NameArgs) -> Result<()> {
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
dir.unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
})
}
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let keypair = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("Failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let keypair =
gitlawb_core::identity::Keypair::from_pem(&pem).context("Failed to parse identity PEM")?;
Ok(keypair.did().to_string())
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let keypair = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("Failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let keypair =
gitlawb_core::identity::Keypair::from_pem(&pem).context("Failed to parse identity PEM")?;
let did = keypair.did();
let vk = keypair.verifying_key();
let doc = gitlawb_core::did::DidDocument::new(did.clone(), &vk);
@@ -190,21 +218,18 @@ fn build_write_provider(
private_key: &str,
rpc_url: &str,
) -> Result<impl alloy::providers::Provider + Clone> {
let signer: PrivateKeySigner = private_key.trim()
let signer: PrivateKeySigner = private_key
.trim()
.parse()
.context("Invalid Ethereum private key (expected 0x-prefixed hex)")?;
let wallet = EthereumWallet::from(signer);
let url: reqwest::Url = rpc_url.parse()
.context("Invalid RPC URL")?;
let provider = ProviderBuilder::new()
.wallet(wallet)
.connect_http(url);
let url: reqwest::Url = rpc_url.parse().context("Invalid RPC URL")?;
let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
Ok(provider)
}
fn build_read_provider(rpc_url: &str) -> Result<impl alloy::providers::Provider + Clone> {
let url: reqwest::Url = rpc_url.parse()
.context("Invalid RPC URL")?;
let url: reqwest::Url = rpc_url.parse().context("Invalid RPC URL")?;
Ok(ProviderBuilder::new().connect_http(url))
}
+84 -33
View File
@@ -35,7 +35,6 @@ pub enum NodeCmd {
},
// ── On-chain PoS ──────────────────────────────────────────────────────
/// Stake $GITLAWB and register this node on-chain (Base L2)
Register {
/// Amount of $GITLAWB to stake (whole tokens, e.g. 10000)
@@ -125,18 +124,47 @@ pub async fn run(args: NodeArgs) -> Result<()> {
NodeCmd::Status { node } => cmd_status(node).await,
NodeCmd::Trust { did, node } => cmd_trust(did, node).await,
NodeCmd::Resolve { did, node } => cmd_resolve(did, node).await,
NodeCmd::Register { stake, http_url, private_key, token, contract, rpc_url, dir } =>
node_stake::cmd_register(stake, http_url, private_key, token, contract, rpc_url, dir).await,
NodeCmd::Heartbeat { private_key, contract, rpc_url, dir } =>
node_stake::cmd_heartbeat(private_key, contract, rpc_url, dir).await,
NodeCmd::OnchainStatus { contract, rpc_url, dir } =>
node_stake::cmd_onchain_status(contract, rpc_url, dir).await,
NodeCmd::Claim { private_key, contract, rpc_url, dir } =>
node_stake::cmd_claim(private_key, contract, rpc_url, dir).await,
NodeCmd::UnstakeRequest { private_key, contract, rpc_url, dir } =>
node_stake::cmd_unstake_request(private_key, contract, rpc_url, dir).await,
NodeCmd::Unstake { private_key, contract, rpc_url, dir } =>
node_stake::cmd_unstake(private_key, contract, rpc_url, dir).await,
NodeCmd::Register {
stake,
http_url,
private_key,
token,
contract,
rpc_url,
dir,
} => {
node_stake::cmd_register(stake, http_url, private_key, token, contract, rpc_url, dir)
.await
}
NodeCmd::Heartbeat {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_heartbeat(private_key, contract, rpc_url, dir).await,
NodeCmd::OnchainStatus {
contract,
rpc_url,
dir,
} => node_stake::cmd_onchain_status(contract, rpc_url, dir).await,
NodeCmd::Claim {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_claim(private_key, contract, rpc_url, dir).await,
NodeCmd::UnstakeRequest {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_unstake_request(private_key, contract, rpc_url, dir).await,
NodeCmd::Unstake {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_unstake(private_key, contract, rpc_url, dir).await,
}
}
@@ -153,9 +181,13 @@ async fn cmd_status(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
// ── Fetch node info (required — bail if unreachable) ──────────────────
let info_resp = client.get("/").await
let info_resp = client
.get("/")
.await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?;
let info: Value = info_resp.json().await
let info: Value = info_resp
.json()
.await
.map_err(|e| anyhow::anyhow!("Invalid JSON from node: {e}"))?;
let did = info["did"].as_str().unwrap_or("unknown");
@@ -189,10 +221,18 @@ async fn cmd_status(node: String) -> Result<()> {
println!("Network");
if let Some(ref peers) = peers_val {
let count = peers["count"].as_u64().unwrap_or_else(|| {
peers["peers"].as_array().map(|a| a.len() as u64).unwrap_or(0)
peers["peers"]
.as_array()
.map(|a| a.len() as u64)
.unwrap_or(0)
});
let reachable = peers["peers"].as_array()
.map(|a| a.iter().filter(|p| p["reachable"].as_bool().unwrap_or(false)).count())
let reachable = peers["peers"]
.as_array()
.map(|a| {
a.iter()
.filter(|p| p["reachable"].as_bool().unwrap_or(false))
.count()
})
.unwrap_or(0);
println!(" Peers: {count} known ({reachable} reachable)");
} else {
@@ -204,9 +244,7 @@ async fn cmd_status(node: String) -> Result<()> {
let peer_id = p2p["peer_id"].as_str().unwrap_or("unknown");
println!(" P2P: enabled — peer_id: {peer_id}");
if let Some(topics) = p2p["topics"].as_array() {
let topic_list: Vec<&str> = topics.iter()
.filter_map(|t| t.as_str())
.collect();
let topic_list: Vec<&str> = topics.iter().filter_map(|t| t.as_str()).collect();
if !topic_list.is_empty() {
println!(" Topics: {}", topic_list.join(", "));
}
@@ -245,8 +283,7 @@ async fn cmd_status(node: String) -> Result<()> {
if let Some(ref events) = events_val {
println!("Activity (recent ref-updates)");
// Events may be a top-level array or wrapped in an "events" key
let items: Option<&Vec<Value>> = events.as_array()
.or_else(|| events["events"].as_array());
let items: Option<&Vec<Value>> = events.as_array().or_else(|| events["events"].as_array());
if let Some(arr) = items {
if arr.is_empty() {
@@ -255,7 +292,8 @@ async fn cmd_status(node: String) -> Result<()> {
for ev in arr.iter().take(5) {
let repo = ev["repo"].as_str().unwrap_or("?");
let ref_name = ev["ref"].as_str().unwrap_or("?");
let ts = ev["timestamp"].as_str()
let ts = ev["timestamp"]
.as_str()
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {ts} {repo} {ref_name}");
@@ -270,9 +308,9 @@ async fn cmd_status(node: String) -> Result<()> {
// Pins
println!("Pins");
if let Some(ref pins) = pins_val {
let count = pins["count"].as_u64().unwrap_or_else(|| {
pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0)
});
let count = pins["count"]
.as_u64()
.unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
println!(" Pinned CIDs: {count}");
} else {
println!(" IPFS not configured");
@@ -285,7 +323,9 @@ async fn cmd_status(node: String) -> Result<()> {
async fn cmd_trust(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/agents/{did}/trust");
let resp = client.get(&path).await
let resp = client
.get(&path)
.await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?;
if !resp.status().is_success() {
@@ -293,7 +333,9 @@ async fn cmd_trust(did: String, node: String) -> Result<()> {
anyhow::bail!("trust query failed ({status}) for {did}");
}
let trust: Value = resp.json().await
let trust: Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("Invalid JSON response: {e}"))?;
let score = trust["trust_score"].as_f64().unwrap_or(0.0);
@@ -310,9 +352,12 @@ async fn cmd_trust(did: String, node: String) -> Result<()> {
async fn cmd_resolve(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let info: Value = client.get("/").await
let info: Value = client
.get("/")
.await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?
.json().await
.json()
.await
.map_err(|e| anyhow::anyhow!("Invalid JSON from node: {e}"))?;
let node_did = info["did"].as_str().unwrap_or("unknown");
@@ -322,8 +367,14 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
println!("DID resolution for {did}");
println!(" DID: {node_did}");
println!(" Node URL: {node}");
println!(" Version: {}", info["version"].as_str().unwrap_or("unknown"));
println!(" Network: {}", info["network"].as_str().unwrap_or("unknown"));
println!(
" Version: {}",
info["version"].as_str().unwrap_or("unknown")
);
println!(
" Network: {}",
info["network"].as_str().unwrap_or("unknown")
);
if let Some(peer_id) = info["p2p_peer_id"].as_str() {
println!(" P2P ID: {peer_id}");
}
+85 -22
View File
@@ -89,7 +89,11 @@ pub async fn cmd_register(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
// 1. Check balance
let bal = token_c.balanceOf(operator_addr).call().await.context("balanceOf failed")?;
let bal = token_c
.balanceOf(operator_addr)
.call()
.await
.context("balanceOf failed")?;
if bal < stake_wei {
anyhow::bail!(
"insufficient $GITLAWB balance: have {}, need {}",
@@ -111,8 +115,14 @@ pub async fn cmd_register(
.send()
.await
.context("approve failed")?;
let approve_receipt = approve_tx.get_receipt().await.context("approve receipt failed")?;
println!(" approved: {}", explorer_tx_url(&rpc_url, &format!("{:?}", approve_receipt.transaction_hash)));
let approve_receipt = approve_tx
.get_receipt()
.await
.context("approve receipt failed")?;
println!(
" approved: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", approve_receipt.transaction_hash))
);
}
// 3. Register
@@ -122,11 +132,17 @@ pub async fn cmd_register(
.send()
.await
.context("registerNode failed")?;
let receipt = register_tx.get_receipt().await.context("registerNode receipt failed")?;
let receipt = register_tx
.get_receipt()
.await
.context("registerNode receipt failed")?;
println!();
println!("✓ Node registered");
println!(" Tx: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(
" Tx: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!(" Operator wallet: {operator_addr}");
println!();
println!("Next: set these env vars on the node:");
@@ -154,9 +170,16 @@ pub async fn cmd_heartbeat(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Posting heartbeat for {did}...");
let tx = staking.heartbeat(did_hash).send().await.context("heartbeat failed")?;
let tx = staking
.heartbeat(did_hash)
.send()
.await
.context("heartbeat failed")?;
let receipt = tx.get_receipt().await.context("heartbeat receipt failed")?;
println!("✓ heartbeat sent: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(
"✓ heartbeat sent: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
Ok(())
}
@@ -173,7 +196,11 @@ pub async fn cmd_onchain_status(
let provider = ProviderBuilder::new().connect_http(url);
let staking = GitlawbNodeStaking::new(contract_addr, provider);
let info = staking.getNodeInfo(did_hash).call().await.context("getNodeInfo failed")?;
let info = staking
.getNodeInfo(did_hash)
.call()
.await
.context("getNodeInfo failed")?;
println!("On-chain status for {did}");
println!();
@@ -190,9 +217,15 @@ pub async fn cmd_onchain_status(
println!(" Registered: {} (unix)", info.registeredAt);
println!(" Active flag: {}", info.active);
println!(" Currently active: {}", info.currentlyActive);
println!(" Pending rewards: {} $GITLAWB", wei_to_tokens(info.pendingRewards));
println!(
" Pending rewards: {} $GITLAWB",
wei_to_tokens(info.pendingRewards)
);
if info.unstakeRequestAt > U256::ZERO {
println!(" Unstake pending: yes (requested at unix {})", info.unstakeRequestAt);
println!(
" Unstake pending: yes (requested at unix {})",
info.unstakeRequestAt
);
}
Ok(())
@@ -215,9 +248,16 @@ pub async fn cmd_claim(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Claiming rewards for {did}...");
let tx = staking.claimRewards(did_hash).send().await.context("claimRewards failed")?;
let tx = staking
.claimRewards(did_hash)
.send()
.await
.context("claimRewards failed")?;
let receipt = tx.get_receipt().await.context("claim receipt failed")?;
println!("✓ rewards claimed: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(
"✓ rewards claimed: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
Ok(())
}
@@ -238,9 +278,19 @@ pub async fn cmd_unstake_request(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Requesting unstake (starts 7-day cooldown)...");
let tx = staking.requestUnstake(did_hash).send().await.context("requestUnstake failed")?;
let receipt = tx.get_receipt().await.context("requestUnstake receipt failed")?;
println!("✓ unstake requested: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
let tx = staking
.requestUnstake(did_hash)
.send()
.await
.context("requestUnstake failed")?;
let receipt = tx
.get_receipt()
.await
.context("requestUnstake receipt failed")?;
println!(
"✓ unstake requested: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!(" Run `gl node unstake` after 7 days to complete withdrawal.");
Ok(())
}
@@ -262,9 +312,16 @@ pub async fn cmd_unstake(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Completing unstake...");
let tx = staking.unstake(did_hash).send().await.context("unstake failed")?;
let tx = staking
.unstake(did_hash)
.send()
.await
.context("unstake failed")?;
let receipt = tx.get_receipt().await.context("unstake receipt failed")?;
println!("✓ unstaked: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(
"✓ unstaked: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
Ok(())
}
@@ -272,13 +329,19 @@ pub async fn cmd_unstake(
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let base = dir.unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let path = base.join("identity.pem");
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let kp = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let kp =
gitlawb_core::identity::Keypair::from_pem(&pem).context("failed to parse identity PEM")?;
Ok(kp.did().to_string())
}
+39 -9
View File
@@ -51,7 +51,11 @@ pub enum PeerCmd {
pub async fn run(args: PeerArgs) -> Result<()> {
match args.cmd {
PeerCmd::List { node } => cmd_list(node).await,
PeerCmd::Add { peer_url, node, dir } => cmd_add(peer_url, node, dir).await,
PeerCmd::Add {
peer_url,
node,
dir,
} => cmd_add(peer_url, node, dir).await,
PeerCmd::Ping { did, node } => cmd_ping(did, node).await,
PeerCmd::Resolve { did, node } => cmd_resolve(did, node).await,
}
@@ -59,7 +63,11 @@ pub async fn run(args: PeerArgs) -> Result<()> {
async fn cmd_list(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let resp: Value = client.get("/api/v1/peers").await?.json().await
let resp: Value = client
.get("/api/v1/peers")
.await?
.json()
.await
.context("failed to list peers")?;
let peers = resp["peers"].as_array().cloned().unwrap_or_default();
@@ -76,7 +84,10 @@ async fn cmd_list(node: String) -> Result<()> {
let did = peer["did"].as_str().unwrap_or("?");
let url = peer["http_url"].as_str().unwrap_or("?");
let reachable = peer["reachable"].as_bool().unwrap_or(false);
let last_seen = peer["last_seen"].as_str().map(|s| &s[..10]).unwrap_or("never");
let last_seen = peer["last_seen"]
.as_str()
.map(|s| &s[..10])
.unwrap_or("never");
let status = if reachable { "✓" } else { "✗" };
println!(" {status} {url}");
println!(" did: {did}");
@@ -92,9 +103,14 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
// Fetch our node's public URL so we can announce it to the peer
let local_client = NodeClient::new(&node, None);
let node_info: Value = local_client.get("/").await?.json().await
let node_info: Value = local_client
.get("/")
.await?
.json()
.await
.context("failed to fetch local node info")?;
let my_url = node_info["public_url"].as_str()
let my_url = node_info["public_url"]
.as_str()
.unwrap_or(&node)
.to_string();
@@ -106,7 +122,9 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
let remote_client = NodeClient::new(&peer_url, Some(keypair));
let announce_path = "/api/v1/peers/announce";
let resp = remote_client.post(announce_path, &body).await
let resp = remote_client
.post(announce_path, &body)
.await
.context("failed to connect to peer")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -143,12 +161,20 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
async fn cmd_ping(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/peers/{did}/ping");
let resp: Value = client.get(&path).await?.json().await
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to ping peer")?;
let url = resp["http_url"].as_str().unwrap_or("?");
let reachable = resp["reachable"].as_bool().unwrap_or(false);
let status = if reachable { "reachable" } else { "unreachable" };
let status = if reachable {
"reachable"
} else {
"unreachable"
};
println!("Peer: {did}");
println!(" URL: {url}");
@@ -160,7 +186,11 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let encoded = urlencoding::encode(&did);
let path = format!("/api/v1/resolve/{encoded}");
let resp: Value = client.get(&path).await?.json().await
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to resolve DID")?;
let source = resp["source"].as_str().unwrap_or("not found");
+231 -64
View File
@@ -128,18 +128,56 @@ pub enum PrCmd {
pub async fn run(args: PrArgs) -> Result<()> {
match args.cmd {
PrCmd::Create { repo, head, base, title, body, owner, node, dir } =>
cmd_create(repo, head, base, title, body, owner, node, dir).await,
PrCmd::Create {
repo,
head,
base,
title,
body,
owner,
node,
dir,
} => cmd_create(repo, head, base, title, body, owner, node, dir).await,
PrCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
PrCmd::View { repo, number, node, dir } => cmd_view(repo, number, node, dir).await,
PrCmd::Diff { repo, number, node, dir } => cmd_diff(repo, number, node, dir).await,
PrCmd::Merge { repo, number, node, dir } => cmd_merge(repo, number, node, dir).await,
PrCmd::Review { repo, number, status, body, node, dir } =>
cmd_review(repo, number, status, body, node, dir).await,
PrCmd::Comment { repo, number, body, node, dir } =>
cmd_comment(repo, number, body, node, dir).await,
PrCmd::Comments { repo, number, node, dir } =>
cmd_comments(repo, number, node, dir).await,
PrCmd::View {
repo,
number,
node,
dir,
} => cmd_view(repo, number, node, dir).await,
PrCmd::Diff {
repo,
number,
node,
dir,
} => cmd_diff(repo, number, node, dir).await,
PrCmd::Merge {
repo,
number,
node,
dir,
} => cmd_merge(repo, number, node, dir).await,
PrCmd::Review {
repo,
number,
status,
body,
node,
dir,
} => cmd_review(repo, number, status, body, node, dir).await,
PrCmd::Comment {
repo,
number,
body,
node,
dir,
} => cmd_comment(repo, number, body, node, dir).await,
PrCmd::Comments {
repo,
number,
node,
dir,
} => cmd_comments(repo, number, node, dir).await,
}
}
@@ -156,7 +194,9 @@ fn detect_remote_owner() -> Option<String> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() { return None; }
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
@@ -166,8 +206,14 @@ fn detect_remote_owner() -> Option<String> {
#[allow(clippy::too_many_arguments)]
async fn cmd_create(
repo: String, head: String, base: String, title: String,
body: Option<String>, owner_override: Option<String>, node: String, dir: Option<PathBuf>,
repo: String,
head: String,
base: String,
title: String,
body: Option<String>,
owner_override: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
// Priority: --owner flag > git remote origin > caller's own DID
@@ -183,7 +229,9 @@ async fn cmd_create(
"target_branch": base,
}))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload).await
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload)
.await
.context("failed to connect to node")?;
let status = resp.status();
let pr: Value = resp.json().await.context("invalid JSON")?;
@@ -206,8 +254,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?
.json()
.await
.context("invalid JSON")?;
let prs = resp["pulls"].as_array().cloned().unwrap_or_default();
if prs.is_empty() {
@@ -223,8 +275,17 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let source = pr["source_branch"].as_str().unwrap_or("?");
let target = pr["target_branch"].as_str().unwrap_or("?");
let author = pr["author_did"].as_str().unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let status_icon = match status { "open" => "○", "merged" => "✓", "closed" => "✗", _ => "?" };
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let status_icon = match status {
"open" => "○",
"merged" => "✓",
"closed" => "✗",
_ => "?",
};
println!(" {status_icon} #{number} {title}");
println!(" {source} → {target} by {author_short}");
println!();
@@ -237,8 +298,12 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let pr: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}")).await?
.json().await.context("invalid JSON")?;
let pr: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?
.json()
.await
.context("invalid JSON")?;
let title = pr["title"].as_str().unwrap_or("?");
let status = pr["status"].as_str().unwrap_or("?");
@@ -256,31 +321,57 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
}
// Show reviews
let reviews: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews")).await?
.json().await.context("invalid JSON")?;
let reviews: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?
.json()
.await
.context("invalid JSON")?;
let reviews = reviews["reviews"].as_array().cloned().unwrap_or_default();
if !reviews.is_empty() {
println!("\nReviews ({}):", reviews.len());
for r in &reviews {
let reviewer = r["reviewer_did"].as_str().unwrap_or("?");
let reviewer_short = reviewer.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let reviewer_short = reviewer
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let rstatus = r["status"].as_str().unwrap_or("?");
let rbody = r["body"].as_str().unwrap_or("");
let icon = match rstatus { "approved" => "✓", "changes_requested" => "✗", _ => "·" };
let icon = match rstatus {
"approved" => "✓",
"changes_requested" => "✗",
_ => "·",
};
println!(" {icon} {reviewer_short}: {rstatus}");
if !rbody.is_empty() { println!(" {rbody}"); }
if !rbody.is_empty() {
println!(" {rbody}");
}
}
}
// Show comments
let comments: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")).await?
.json().await.context("invalid JSON")?;
let comments: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = comments["comments"].as_array().cloned().unwrap_or_default();
if !comments.is_empty() {
println!("\nComments ({}):", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -295,8 +386,12 @@ async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?
.json()
.await
.context("invalid JSON")?;
let diff = resp["diff"].as_str().unwrap_or("");
if diff.is_empty() {
@@ -313,7 +408,12 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&serde_json::json!({}))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"), &body).await
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"),
&body,
)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -330,8 +430,12 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
}
async fn cmd_review(
repo: String, number: u64, status: String, body: Option<String>,
node: String, dir: Option<PathBuf>,
repo: String,
number: u64,
status: String,
body: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
@@ -342,7 +446,12 @@ async fn cmd_review(
"body": body,
}))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"), &payload).await
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"),
&payload,
)
.await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -352,21 +461,33 @@ async fn cmd_review(
anyhow::bail!("review failed ({code}): {msg}");
}
let icon = match status.as_str() { "approved" => "✓", "changes_requested" => "✗", _ => "·" };
let icon = match status.as_str() {
"approved" => "✓",
"changes_requested" => "✗",
_ => "·",
};
println!("{icon} Review submitted: {status} on PR #{number}");
Ok(())
}
async fn cmd_comment(
repo: String, number: u64, body: String,
node: String, dir: Option<PathBuf>,
repo: String,
number: u64,
body: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, Some(keypair));
let payload = serde_json::to_vec(&serde_json::json!({ "body": body }))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"), &payload).await
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"),
&payload,
)
.await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -385,8 +506,14 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -397,7 +524,11 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
println!("Comments on PR #{number} ({} total)\n", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -443,9 +574,13 @@ mod tests {
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -465,9 +600,13 @@ mod tests {
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -553,7 +692,9 @@ mod tests {
let _m2 = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/reviews$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/reviews$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -563,7 +704,9 @@ mod tests {
let _m3 = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -571,9 +714,14 @@ mod tests {
.create_async()
.await;
cmd_view("myrepo".to_string(), 1, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -613,7 +761,9 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/99/comments$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/99/comments$".to_string(),
),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -642,7 +792,9 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -650,9 +802,14 @@ mod tests {
.create_async()
.await;
cmd_comments("myrepo".to_string(), 1, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_comments(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -672,9 +829,14 @@ mod tests {
.create_async()
.await;
cmd_comments("myrepo".to_string(), 2, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_comments(
"myrepo".to_string(),
2,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -694,8 +856,13 @@ mod tests {
.create_async()
.await;
cmd_merge("myrepo".to_string(), 2, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_merge(
"myrepo".to_string(),
2,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
}
+117 -27
View File
@@ -52,13 +52,27 @@ pub enum ProtectCmd {
pub async fn run(args: ProtectArgs) -> Result<()> {
match args.cmd {
ProtectCmd::Set { branch, repo, node, dir } => cmd_set(branch, repo, node, dir).await,
ProtectCmd::Remove { branch, repo, node, dir } => cmd_remove(branch, repo, node, dir).await,
ProtectCmd::Set {
branch,
repo,
node,
dir,
} => cmd_set(branch, repo, node, dir).await,
ProtectCmd::Remove {
branch,
repo,
node,
dir,
} => cmd_remove(branch, repo, node, dir).await,
ProtectCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
}
}
async fn resolve_owner_repo(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
async fn resolve_owner_repo(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
return Ok((owner.to_string(), name.to_string()));
}
@@ -68,7 +82,11 @@ async fn resolve_owner_repo(repo: &str, node: &str, dir: Option<&std::path::Path
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client.get("/").await?.json().await
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -83,7 +101,10 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
let client = NodeClient::new(&node, Some(kp));
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"), b"")
.post(
&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"),
b"",
)
.await
.context("failed to connect to node")?;
@@ -100,14 +121,22 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
Ok(())
}
async fn cmd_remove(branch: String, repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_remove(
branch: String,
repo: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let (owner, name) = resolve_owner_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(kp));
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"), b"")
.delete(
&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"),
b"",
)
.await
.context("failed to connect to node")?;
@@ -140,12 +169,18 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
anyhow::bail!("list protected branches failed ({status}): {msg}");
}
let branches = body["protected_branches"].as_array().cloned().unwrap_or_default();
let branches = body["protected_branches"]
.as_array()
.cloned()
.unwrap_or_default();
if branches.is_empty() {
println!("No protected branches in {owner}/{name}");
} else {
println!("Protected branches in {owner}/{name} ({} total)\n", branches.len());
println!(
"Protected branches in {owner}/{name} ({} total)\n",
branches.len()
);
for b in &branches {
println!(" 🔒 {}", b.as_str().unwrap_or("?"));
}
@@ -162,10 +197,19 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/branches/main/protect".to_string()))
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/branches/main/protect".to_string(),
),
)
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"protected","repo":"z/myrepo","branch":"main"}"#)
@@ -177,7 +221,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -185,10 +231,17 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"branches/main/protect".to_string()))
.mock(
"POST",
mockito::Matcher::Regex(r"branches/main/protect".to_string()),
)
.with_status(400)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"only the repo owner can protect branches"}"#)
@@ -200,7 +253,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap_err();
)
.await
.unwrap_err();
assert!(err.to_string().contains("protect failed"));
}
@@ -209,10 +264,17 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"branches/main/protect".to_string()))
.mock(
"DELETE",
mockito::Matcher::Regex(r"branches/main/protect".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"unprotected","branch":"main"}"#)
@@ -224,7 +286,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -232,17 +296,30 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"branches/protected".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":[],"count":0}"#)
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -250,26 +327,39 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"branches/protected".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
.create_async()
.await;
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[test]
fn test_resolve_owner_repo_with_slash() {
// owner/repo format should split correctly
let rt = tokio::runtime::Runtime::new().unwrap();
let (owner, name) = rt.block_on(
resolve_owner_repo("alice/myrepo", "http://unused", None)
).unwrap();
let (owner, name) = rt
.block_on(resolve_owner_repo("alice/myrepo", "http://unused", None))
.unwrap();
assert_eq!(owner, "alice");
assert_eq!(name, "myrepo");
}
+16 -6
View File
@@ -40,7 +40,9 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// ── Step 1: Identity ──────────────────────────────────────────────────
@@ -124,7 +126,10 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
}
Err(e) => {
println!(" ✗ Could not reach {}: {e}", args.node);
println!(" You can retry later with: gl register --node {}", args.node);
println!(
" You can retry later with: gl register --node {}",
args.node
);
println!();
// Non-fatal — continue
}
@@ -201,11 +206,17 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
let payload: Value = resp.json().await.unwrap_or_default();
let msg = payload["message"].as_str().unwrap_or("unknown");
println!(" ✗ Repo creation failed ({status}): {msg}");
println!(" Try manually: gl repo create {repo_name} --node {}", args.node);
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
);
}
Err(e) => {
println!(" ✗ Could not reach node: {e}");
println!(" Try manually: gl repo create {repo_name} --node {}", args.node);
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
);
}
}
@@ -216,8 +227,7 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
let keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
+9 -6
View File
@@ -67,10 +67,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
}
// Save bootstrap UCAN
let ucan = payload
.get("ucan")
.and_then(|v| v.as_str())
.unwrap_or("");
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
if !ucan.is_empty() {
let ucan_path = ucan_path(args.dir.as_deref())?;
@@ -84,7 +81,10 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
let trust = payload.get("trust_score").and_then(|v| v.as_f64()).unwrap_or(0.0);
let trust = payload
.get("trust_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let expires = payload
.get("expires")
.and_then(|v| v.as_str())
@@ -183,7 +183,10 @@ mod tests {
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid signature"));
assert!(result
.unwrap_err()
.to_string()
.contains("invalid signature"));
}
#[tokio::test]
+328 -54
View File
@@ -118,24 +118,92 @@ pub enum RepoCmd {
#[arg(long)]
json: bool,
},
/// Register this node as a replica of someone else's repo
ReplicaRegister {
/// Repository in owner/repo format
repo: String,
/// Publicly-reachable URL of YOUR node (the one hosting the replica)
#[arg(long)]
url: String,
/// URL of the origin node (where the repo lives)
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Unregister this node as a replica
ReplicaUnregister {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// List nodes currently mirroring a repo
Replicas {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
}
pub async fn run(args: RepoArgs) -> Result<()> {
match args.cmd {
RepoCmd::Create { name, description, private, branch, node, dir } => {
cmd_create(name, description, !private, branch, node, dir).await
}
RepoCmd::Create {
name,
description,
private,
branch,
node,
dir,
} => cmd_create(name, description, !private, branch, node, dir).await,
RepoCmd::List { node, dir } => cmd_list(node, dir).await,
RepoCmd::Clone { name, node, dir } => cmd_clone(name, node, dir).await,
RepoCmd::Info { repo, node, dir } => cmd_info(repo, node, dir).await,
RepoCmd::Commits { repo, branch, limit, node, dir } => {
cmd_commits(repo, branch, limit, node, dir).await
}
RepoCmd::Fork { repo, name, node, dir } => cmd_fork(repo, name, node, dir).await,
RepoCmd::LabelAdd { repo, label, node, dir } => cmd_label_add(repo, label, node, dir).await,
RepoCmd::LabelRemove { repo, label, node, dir } => cmd_label_remove(repo, label, node, dir).await,
RepoCmd::Commits {
repo,
branch,
limit,
node,
dir,
} => cmd_commits(repo, branch, limit, node, dir).await,
RepoCmd::Fork {
repo,
name,
node,
dir,
} => cmd_fork(repo, name, node, dir).await,
RepoCmd::LabelAdd {
repo,
label,
node,
dir,
} => cmd_label_add(repo, label, node, dir).await,
RepoCmd::LabelRemove {
repo,
label,
node,
dir,
} => cmd_label_remove(repo, label, node, dir).await,
RepoCmd::LabelList { repo, node, dir } => cmd_label_list(repo, node, dir).await,
RepoCmd::Owner { repo, node, dir, json } => cmd_owner(repo, node, dir, json).await,
RepoCmd::Owner {
repo,
node,
dir,
json,
} => cmd_owner(repo, node, dir, json).await,
RepoCmd::ReplicaRegister {
repo,
url,
node,
dir,
} => cmd_replica_register(repo, url, node, dir).await,
RepoCmd::ReplicaUnregister { repo, node, dir } => {
cmd_replica_unregister(repo, node, dir).await
}
RepoCmd::Replicas { repo, node } => cmd_replicas(repo, node).await,
}
}
@@ -146,7 +214,11 @@ async fn resolve_owner_did(node: &str, dir: Option<&std::path::Path>) -> Result<
return Ok(did.split(':').next_back().unwrap_or(&did).to_string());
}
let client = NodeClient::new(node, None);
let info: Value = client.get("/").await?.json().await
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
Ok(did.split(':').next_back().unwrap_or(did).to_string())
@@ -163,7 +235,11 @@ async fn cmd_create(
let keypair = load_keypair_from_dir(dir.as_deref())?;
// Derive owner DID before keypair is moved into the client
let owner_did = keypair.did().to_string();
let owner_short = owner_did.split(':').next_back().unwrap_or(&owner_did).to_string();
let owner_short = owner_did
.split(':')
.next_back()
.unwrap_or(&owner_did)
.to_string();
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&json!({
@@ -173,7 +249,9 @@ async fn cmd_create(
"default_branch": default_branch,
}))?;
let resp = client.post("/api/v1/repos", &body).await
let resp = client
.post("/api/v1/repos", &body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON response")?;
@@ -201,7 +279,11 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let client = NodeClient::new(&node, None);
let url = format!("/api/v1/repos?owner={owner}");
let repos: Value = client.get(&url).await?.json().await
let repos: Value = client
.get(&url)
.await?
.json()
.await
.context("failed to list repos")?;
let repos = repos.as_array().context("expected array")?;
@@ -229,7 +311,10 @@ async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<(
} else {
let client = NodeClient::new(&node, None);
let info: Value = client.get("/").await?.json().await?;
info["did"].as_str().context("node missing DID")?.to_string()
info["did"]
.as_str()
.context("node missing DID")?
.to_string()
};
let url = format!("gitlawb://{did}/{name}");
println!(" cloning {url}");
@@ -256,8 +341,10 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
};
let r: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}")).await?
.json().await
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await?
.json()
.await
.context("repo not found")?;
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
@@ -268,13 +355,130 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
println!(" ID: {}", r["id"].as_str().unwrap_or("?"));
println!(" Owner DID: {owner_did}");
println!(" Public: {}", r["is_public"].as_bool().unwrap_or(true));
println!(" Branch: {}", r["default_branch"].as_str().unwrap_or("main"));
println!(
" Branch: {}",
r["default_branch"].as_str().unwrap_or("main")
);
println!(" Clone: git clone {gitlawb_url}");
println!(" HTTP URL: {http_url}");
println!(" Updated: {}", r["updated_at"].as_str().unwrap_or("?"));
if let Some(desc) = r["description"].as_str().filter(|s| !s.is_empty()) {
println!(" Desc: {desc}");
}
// Replica count — failure to fetch is non-fatal (older nodes don't expose this).
if let Ok(resp) = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
{
if resp.status().is_success() {
if let Ok(json) = resp.json::<Value>().await {
if let Some(count) = json["replica_count"].as_i64() {
println!(" Replicas: {count}");
}
}
}
}
Ok(())
}
async fn cmd_replica_register(
repo: String,
url: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format (e.g. did:key:.../myrepo)")?;
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::to_vec(&json!({ "url": url }))?;
let resp = client
.put(&format!("/api/v1/repos/{owner}/{name}/replicas"), &body)
.await
.context("failed to connect to origin node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica register failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Registered as replica of {owner}/{name}");
println!(" Your URL: {url}");
println!(" Replicas: {count} total");
println!();
println!("Next: ensure your node has a copy of the repo —");
println!(" git clone gitlawb://{owner}/{name}");
Ok(())
}
async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{name}/replicas"), b"")
.await
.context("failed to connect to origin node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica unregister failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Unregistered as replica of {owner}/{name} ({count} replicas remaining)");
Ok(())
}
async fn cmd_replicas(repo: String, node: String) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let client = NodeClient::new(&node, None);
let resp = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replicas list failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("{owner}/{name}: {count} replicas");
if let Some(arr) = body["replicas"].as_array() {
for r in arr {
let did = r["replica_did"].as_str().unwrap_or("?");
let url = r["replica_url"].as_str().unwrap_or("?");
let registered = r["registered_at"]
.as_str()
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {registered} {did} → {url}");
}
}
Ok(())
}
@@ -296,7 +500,11 @@ pub(crate) async fn cmd_commits(
};
let url = format!("/api/v1/repos/{owner}/{name}/commits?branch={branch}&limit={limit}");
let resp: Value = client.get(&url).await?.json().await
let resp: Value = client
.get(&url)
.await?
.json()
.await
.context("failed to fetch commits")?;
let commits = resp["commits"].as_array().cloned().unwrap_or_default();
@@ -308,17 +516,20 @@ pub(crate) async fn cmd_commits(
println!("Commits on {branch} ({owner}/{name})");
println!();
for c in &commits {
let sha = c["hash"].as_str()
let sha = c["hash"]
.as_str()
.or_else(|| c["sha"].as_str())
.or_else(|| c["oid"].as_str())
.unwrap_or("?");
let short_sha = &sha[..sha.len().min(10)];
let msg = c["message"].as_str().unwrap_or("(no message)");
let first_line = msg.lines().next().unwrap_or(msg);
let author = c["author_name"].as_str()
let author = c["author_name"]
.as_str()
.or_else(|| c["author"].as_str())
.unwrap_or("?");
let date = c["date"].as_str()
let date = c["date"]
.as_str()
.or_else(|| c["committer_date"].as_str())
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
@@ -327,7 +538,12 @@ pub(crate) async fn cmd_commits(
Ok(())
}
async fn cmd_fork(repo: String, name: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_fork(
repo: String,
name: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let client = NodeClient::new(&node, Some(keypair));
@@ -338,7 +554,9 @@ async fn cmd_fork(repo: String, name: Option<String>, node: String, dir: Option<
};
let body = serde_json::to_vec(&serde_json::json!({ "name": name }))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body).await
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -356,7 +574,11 @@ async fn cmd_fork(repo: String, name: Option<String>, node: String, dir: Option<
}
/// Returns (owner, repo_name) — if repo contains '/', splits on it; otherwise uses caller's DID.
async fn resolve_owner_repo_pair(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
async fn resolve_owner_repo_pair(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
if let Some((o, r)) = repo.split_once('/') {
Ok((o.to_string(), r.to_string()))
} else {
@@ -365,13 +587,20 @@ async fn resolve_owner_repo_pair(repo: &str, node: &str, dir: Option<&std::path:
}
}
async fn cmd_label_add(repo: String, label: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_label_add(
repo: String,
label: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&serde_json::json!({ "label": label }))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body).await
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -390,12 +619,19 @@ async fn cmd_label_add(repo: String, label: String, node: String, dir: Option<Pa
Ok(())
}
async fn cmd_label_remove(repo: String, label: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_label_remove(
repo: String,
label: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let resp = client.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[]).await
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[])
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -413,8 +649,12 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{name}/labels")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await?
.json()
.await
.context("invalid JSON")?;
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
if labels.is_empty() {
@@ -445,10 +685,7 @@ async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: b
anyhow::bail!("repo not found: {owner}/{name}");
}
let info: Value = resp.json().await.context("invalid JSON")?;
let owner_did = info["owner_did"]
.as_str()
.unwrap_or(&owner)
.to_string();
let owner_did = info["owner_did"].as_str().unwrap_or(&owner).to_string();
let owner_short = owner_did
.split(':')
.next_back()
@@ -522,7 +759,9 @@ mod tests {
async fn test_resolve_owner_did_uses_keypair() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let owner = resolve_owner_did("http://unused", Some(dir.path())).await.unwrap();
let owner = resolve_owner_did("http://unused", Some(dir.path()))
.await
.unwrap();
// Should be the key segment of a did:key DID — starts with 'z'
assert!(owner.starts_with('z'));
assert!(!owner.contains(':'));
@@ -589,14 +828,19 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[]"#)
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -613,7 +857,9 @@ mod tests {
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -623,14 +869,21 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/repos\?owner=z".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=z".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[{"name":"myrepo","is_public":true,"updated_at":"2026-03-20T00:00:00Z"}]"#)
.with_body(
r#"[{"name":"myrepo","is_public":true,"updated_at":"2026-03-20T00:00:00Z"}]"#,
)
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_m.assert_async().await;
}
@@ -703,7 +956,9 @@ mod tests {
)
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"fork-1","name":"myrepo","owner_did":"did:key:z6MkMe","is_public":true}"#)
.with_body(
r#"{"id":"fork-1","name":"myrepo","owner_did":"did:key:z6MkMe","is_public":true}"#,
)
.create_async()
.await;
@@ -742,7 +997,10 @@ mod tests {
)
.await
.unwrap_err();
assert!(err.to_string().contains("already have a repo"), "got: {err}");
assert!(
err.to_string().contains("already have a repo"),
"got: {err}"
);
}
#[tokio::test]
@@ -797,7 +1055,9 @@ mod tests {
let _m = server
.mock(
"DELETE",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels/language:rust$".to_string()),
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/labels/language:rust$".to_string(),
),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -832,9 +1092,13 @@ mod tests {
.create_async()
.await;
cmd_label_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -854,9 +1118,13 @@ mod tests {
.create_async()
.await;
cmd_label_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
@@ -876,7 +1144,9 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"name":"myrepo","owner_did":"{did}","is_public":true}}"#))
.with_body(format!(
r#"{{"name":"myrepo","owner_did":"{did}","is_public":true}}"#
))
.create_async()
.await;
let _prot = server
@@ -915,7 +1185,9 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#)
.with_body(
r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#,
)
.create_async()
.await;
let _prot = server
@@ -954,7 +1226,9 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#)
.with_body(
r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#,
)
.create_async()
.await;
let _prot = server
+59 -22
View File
@@ -45,9 +45,9 @@ pub enum StarCmd {
pub async fn run(args: StarArgs) -> Result<()> {
match args.cmd {
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
StarCmd::Remove { repo, node, dir } => cmd_remove(repo, node, dir).await,
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
}
}
@@ -55,8 +55,8 @@ fn resolve_owner_repo(repo: &str, dir: Option<&std::path::Path>) -> Result<(Stri
if let Some((owner, name)) = repo.split_once('/') {
return Ok((owner.to_string(), name.to_string()));
}
let kp = load_keypair_from_dir(dir)
.context("identity not found — run `gl identity new` first")?;
let kp =
load_keypair_from_dir(dir).context("identity not found — run `gl identity new` first")?;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
Ok((short, repo.to_string()))
@@ -111,7 +111,8 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
}
async fn cmd_count(repo: String, node: String) -> Result<()> {
let (owner, name) = repo.split_once('/')
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format for count (e.g. alice/myrepo)")?;
let client = NodeClient::new(&node, None);
@@ -143,10 +144,17 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/star$".to_string()))
.mock(
"PUT",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/star$".to_string()),
)
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"starred","repo":"z/myrepo","star_count":1}"#)
@@ -157,7 +165,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -165,7 +175,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -180,7 +194,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -190,7 +206,9 @@ mod tests {
"owner/myrepo".to_string(),
"http://127.0.0.1:1".to_string(),
Some(dir.path().to_path_buf()),
).await.unwrap_err();
)
.await
.unwrap_err();
assert!(err.to_string().contains("identity not found"));
}
@@ -199,7 +217,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -213,7 +235,9 @@ mod tests {
"owner/missing".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap_err();
)
.await
.unwrap_err();
assert!(err.to_string().contains("star failed"));
}
@@ -222,7 +246,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -236,7 +264,9 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap();
)
.await
.unwrap();
}
#[tokio::test]
@@ -244,7 +274,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -258,7 +292,9 @@ mod tests {
"owner/missing".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
).await.unwrap_err();
)
.await
.unwrap_err();
assert!(err.to_string().contains("unstar failed"));
}
@@ -274,15 +310,16 @@ mod tests {
.create_async()
.await;
cmd_count("alice/myrepo".to_string(), server.url()).await.unwrap();
cmd_count("alice/myrepo".to_string(), server.url())
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_count_requires_slash() {
let err = cmd_count(
"noslash".to_string(),
"http://127.0.0.1:1".to_string(),
).await.unwrap_err();
let err = cmd_count("noslash".to_string(), "http://127.0.0.1:1".to_string())
.await
.unwrap_err();
assert!(err.to_string().contains("owner/repo format"));
}
+29 -9
View File
@@ -84,7 +84,8 @@ pub async fn run(args: StatusArgs) -> Result<()> {
if let Ok(r) = pr_resp {
if let Ok(body) = r.json::<Value>().await {
let prs = body["pulls"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = prs.iter()
let open: Vec<_> = prs
.iter()
.filter(|p| p["status"].as_str() == Some("open"))
.collect();
if open.is_empty() {
@@ -110,7 +111,8 @@ pub async fn run(args: StatusArgs) -> Result<()> {
if let Ok(r) = issue_resp {
if let Ok(body) = r.json::<Value>().await {
let issues = body["issues"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = issues.iter()
let open: Vec<_> = issues
.iter()
.filter(|i| i["status"].as_str() == Some("open"))
.collect();
if open.is_empty() {
@@ -148,13 +150,17 @@ fn detect_gitlawb_remote() -> Option<(String, String)> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() { return None; }
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() { return None; }
if did.is_empty() || repo.is_empty() {
return None;
}
Some((did, repo))
}
@@ -165,7 +171,9 @@ fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() { return None; }
if did.is_empty() || repo.is_empty() {
return None;
}
Some((did, repo))
}
@@ -176,13 +184,19 @@ mod tests {
#[test]
fn parse_simple_gitlawb_url() {
let result = parse_gitlawb_url("gitlawb://did:key:z6Mk1234/myrepo");
assert_eq!(result, Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string())));
assert_eq!(
result,
Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string()))
);
}
#[test]
fn parse_gitlawb_url_with_newline() {
let result = parse_gitlawb_url("gitlawb://did:key:z6Mk1234/myrepo\n");
assert_eq!(result, Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string())));
assert_eq!(
result,
Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string()))
);
}
#[test]
@@ -204,7 +218,10 @@ mod tests {
#[test]
fn parse_gitlawb_url_repo_name_with_dash() {
let result = parse_gitlawb_url("gitlawb://did:key:z6MkAbc/my-cool-repo");
assert_eq!(result, Some(("did:key:z6MkAbc".to_string(), "my-cool-repo".to_string())));
assert_eq!(
result,
Some(("did:key:z6MkAbc".to_string(), "my-cool-repo".to_string()))
);
}
#[tokio::test]
@@ -279,7 +296,10 @@ mod tests {
.create_async()
.await;
let _trust = server
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
+116 -27
View File
@@ -93,16 +93,49 @@ pub enum TaskCmd {
pub async fn run(args: TaskArgs) -> Result<()> {
match args.cmd {
TaskCmd::Create {
kind, capability, repo_id, assignee_did, payload,
ucan_token, deadline, node, dir,
} => cmd_create(kind, capability, repo_id, assignee_did, payload, ucan_token, deadline, node, dir).await,
TaskCmd::List { status, assignee_did, limit, node } => {
cmd_list(status, assignee_did, limit, node).await
kind,
capability,
repo_id,
assignee_did,
payload,
ucan_token,
deadline,
node,
dir,
} => {
cmd_create(
kind,
capability,
repo_id,
assignee_did,
payload,
ucan_token,
deadline,
node,
dir,
)
.await
}
TaskCmd::List {
status,
assignee_did,
limit,
node,
} => cmd_list(status, assignee_did, limit, node).await,
TaskCmd::View { id, node } => cmd_view(id, node).await,
TaskCmd::Claim { id, node, dir } => cmd_claim(id, node, dir).await,
TaskCmd::Complete { id, result, node, dir } => cmd_complete(id, result, node, dir).await,
TaskCmd::Fail { id, reason, node, dir } => cmd_fail(id, reason, node, dir).await,
TaskCmd::Complete {
id,
result,
node,
dir,
} => cmd_complete(id, result, node, dir).await,
TaskCmd::Fail {
id,
reason,
node,
dir,
} => cmd_fail(id, reason, node, dir).await,
}
}
@@ -258,7 +291,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks")
@@ -289,7 +326,11 @@ mod tests {
let err = cmd_create(
"code-review".to_string(),
"agent:task".to_string(),
None, None, None, None, None,
None,
None,
None,
None,
None,
"http://127.0.0.1:1".to_string(),
Some(dir.path().to_path_buf()),
)
@@ -303,7 +344,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks")
@@ -317,7 +362,11 @@ mod tests {
cmd_create(
"deploy".to_string(),
"agent:task".to_string(),
None, None, None, None, None,
None,
None,
None,
None,
None,
server.url(),
Some(dir.path().to_path_buf()),
)
@@ -332,7 +381,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tasks":[]}"#)
@@ -347,7 +399,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"status=pending".to_string()))
.mock(
"GET",
mockito::Matcher::Regex(r"status=pending".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}]}"#)
@@ -404,7 +459,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/task-7/claim")
@@ -414,9 +473,13 @@ mod tests {
.create_async()
.await;
cmd_claim("task-7".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_claim(
"task-7".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
// ── complete ─────────────────────────────────────────────────────
@@ -426,7 +489,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/task-7/complete")
@@ -451,7 +518,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/task-8/complete")
@@ -461,9 +532,14 @@ mod tests {
.create_async()
.await;
cmd_complete("task-8".to_string(), None, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_complete(
"task-8".to_string(),
None,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
// ── fail ─────────────────────────────────────────────────────────
@@ -473,7 +549,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/task-9/fail")
@@ -498,7 +578,11 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/task-10/fail")
@@ -508,8 +592,13 @@ mod tests {
.create_async()
.await;
cmd_fail("task-10".to_string(), None, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_fail(
"task-10".to_string(),
None,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
}
+22 -12
View File
@@ -81,7 +81,9 @@ async fn cmd_delegate(
json_out: bool,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let audience: Did = to.parse().map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
let audience: Did = to
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
let exp = expiry.map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64));
let ucan = Ucan::issue(&keypair, audience, vec![Capability::new(&cap, &can)], exp)?;
@@ -94,21 +96,27 @@ async fn cmd_delegate(
}
if json_out {
println!("{}", serde_json::to_string_pretty(&json!({
"issuer": ucan.payload.iss.to_string(),
"audience": ucan.payload.aud.to_string(),
"capability": { "with": cap, "can": can },
"expires": ucan.payload.exp,
"token": encoded,
}))?);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"issuer": ucan.payload.iss.to_string(),
"audience": ucan.payload.aud.to_string(),
"capability": { "with": cap, "can": can },
"expires": ucan.payload.exp,
"token": encoded,
}))?
);
} else {
println!("Issuer: {}", ucan.payload.iss);
println!("Audience: {}", ucan.payload.aud);
println!("Cap: {} → {}", cap, can);
if let Some(exp) = ucan.payload.exp {
println!("Expires: {}", chrono::DateTime::from_timestamp(exp, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| exp.to_string()));
println!(
"Expires: {}",
chrono::DateTime::from_timestamp(exp, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| exp.to_string())
);
} else {
println!("Expires: never");
}
@@ -335,6 +343,8 @@ mod tests {
let path = dir.path().join("token.json");
std::fs::write(&path, ucan.encode().unwrap()).unwrap();
cmd_verify(path.to_string_lossy().to_string()).await.unwrap();
cmd_verify(path.to_string_lossy().to_string())
.await
.unwrap();
}
}
+75 -22
View File
@@ -58,24 +58,40 @@ pub enum WebhookCmd {
pub async fn run(args: WebhookArgs) -> Result<()> {
match args.cmd {
WebhookCmd::Create { repo, url, events, secret, node, dir } =>
cmd_create(repo, url, events, secret, node, dir).await,
WebhookCmd::List { repo, node } =>
cmd_list(repo, node).await,
WebhookCmd::Delete { repo, id, node, dir } =>
cmd_delete(repo, id, node, dir).await,
WebhookCmd::Create {
repo,
url,
events,
secret,
node,
dir,
} => cmd_create(repo, url, events, secret, node, dir).await,
WebhookCmd::List { repo, node } => cmd_list(repo, node).await,
WebhookCmd::Delete {
repo,
id,
node,
dir,
} => cmd_delete(repo, id, node, dir).await,
}
}
async fn resolve_owner(client: &NodeClient) -> Result<String> {
let info: Value = client.get("/").await?.json().await?;
let did = info["did"].as_str().context("node missing DID")?.to_string();
let did = info["did"]
.as_str()
.context("node missing DID")?
.to_string();
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
}
async fn cmd_create(
repo: String, url: String, events: String, secret: Option<String>,
node: String, dir: Option<PathBuf>,
repo: String,
url: String,
events: String,
secret: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let client = NodeClient::new(&node, Some(keypair));
@@ -89,7 +105,9 @@ async fn cmd_create(
"events": event_list,
}))?;
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &payload).await
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &payload)
.await
.context("failed to connect to node")?;
let status = resp.status();
let hook: Value = resp.json().await.context("invalid JSON")?;
@@ -100,16 +118,27 @@ async fn cmd_create(
}
let id = hook["id"].as_str().unwrap_or("?");
let hook_events = hook["events"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
let hook_events = hook["events"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| events.clone());
let has_secret = hook["secret"].as_str().map(|s| !s.is_empty()).unwrap_or(false);
let has_secret = hook["secret"]
.as_str()
.map(|s| !s.is_empty())
.unwrap_or(false);
println!("✓ Webhook created");
println!(" ID: {id}");
println!(" URL: {url}");
println!(" Events: {hook_events}");
if has_secret { println!(" Secret: set (HMAC-SHA256 signing enabled)"); }
if has_secret {
println!(" Secret: set (HMAC-SHA256 signing enabled)");
}
println!("\n Delete: gl webhook delete {repo} {id}");
Ok(())
}
@@ -118,8 +147,12 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let owner = resolve_owner(&client).await?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/hooks")).await?
.json().await.context("invalid JSON")?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
.await
.context("invalid JSON")?;
let hooks = resp["webhooks"].as_array().cloned().unwrap_or_default();
if hooks.is_empty() {
@@ -131,8 +164,14 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
for hook in &hooks {
let id = hook["id"].as_str().unwrap_or("?");
let url = hook["url"].as_str().unwrap_or("?");
let events = hook["events"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
let events = hook["events"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let active = hook["active"].as_bool().unwrap_or(true);
let status = if active { "active" } else { "inactive" };
@@ -151,7 +190,11 @@ async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>
let payload = serde_json::to_vec(&serde_json::json!({}))?;
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &payload).await
.delete(
&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"),
&payload,
)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -184,7 +227,11 @@ mod tests {
fn tmp_identity() -> (tempfile::TempDir, gitlawb_core::identity::Keypair) {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
(dir, kp)
}
@@ -328,7 +375,10 @@ mod tests {
let _root = mock_root(&mut server).await;
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/hooks/hook-1$".to_string()))
.mock(
"DELETE",
mockito::Matcher::Regex(r"/hooks/hook-1$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
@@ -352,7 +402,10 @@ mod tests {
let _root = mock_root(&mut server).await;
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/hooks/nope$".to_string()))
.mock(
"DELETE",
mockito::Matcher::Regex(r"/hooks/nope$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"webhook not found"}"#)
+6 -3
View File
@@ -23,15 +23,18 @@ services:
postgres:
condition: service_healthy
ports:
- "7545:7545" # HTTP API + git smart-HTTP
- "7546:7546" # libp2p
- "${GITLAWB_PORT:-7545}:${GITLAWB_PORT:-7545}" # HTTP API + git smart-HTTP
- "${GITLAWB_P2P_PORT:-7546}:${GITLAWB_P2P_PORT:-7546}" # libp2p
volumes:
- gitlawb-data:/data
environment:
DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb
GITLAWB_HOST: 0.0.0.0
GITLAWB_PORT: ${GITLAWB_PORT:-7545}
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
GITLAWB_P2P_PORT: 7546
GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546}
# Sync
GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false}
# On-chain PoS (optional — leave unset for local/dev)
GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-}
GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-}
+51
View File
@@ -0,0 +1,51 @@
app = "gitlawb-node-test"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
GITLAWB_HOST = "0.0.0.0"
GITLAWB_PORT = "7545"
GITLAWB_P2P_PORT = "7546"
GITLAWB_REPOS_DIR = "/data/repos"
GITLAWB_KEY = "/data/keys/identity.pem"
GITLAWB_PUBLIC_URL = "https://gitlawb-node-test.fly.dev"
GITLAWB_BOOTSTRAP_PEERS = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com"
GITLAWB_AUTO_SYNC = "true"
GITLAWB_MAX_PACK_BYTES = "524288000"
[http_service]
internal_port = 7545
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = "connections"
hard_limit = 500
soft_limit = 400
[http_service.http_options]
idle_timeout = 600
[[services]]
protocol = "tcp"
internal_port = 7546
[[services.ports]]
port = 7546
[[vm]]
size = "shared-cpu-1x"
memory = 1024
[[mounts]]
source = "gitlawb_test_data"
destination = "/data"
initial_size = "3gb"
[metrics]
port = 9091
path = "/metrics"
+1
View File
@@ -0,0 +1 @@
.build/
+20
View File
@@ -0,0 +1,20 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "GitlawbNode",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "GitlawbNode",
path: "Sources/GitlawbNode",
exclude: ["Info.plist"],
resources: [
.copy("Resources/docker-compose.yml"),
.copy("Resources/MenuBarIcon.png"),
.copy("Resources/[email protected]"),
.copy("Resources/AppIcon.icns"),
]
),
]
)
@@ -0,0 +1,30 @@
import AppKit
import ServiceManagement
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusBarController: StatusBarController?
private let dockerCompose = DockerCompose()
func applicationDidFinishLaunching(_ notification: Notification) {
// Hide from Dock — menu bar only
NSApp.setActivationPolicy(.accessory)
statusBarController = StatusBarController(dockerCompose: dockerCompose)
// Auto-start node if preference is set
if Config.shared.autoStartOnLaunch {
dockerCompose.start()
}
// Begin polling status
dockerCompose.startPolling()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return false
}
func applicationWillTerminate(_ notification: Notification) {
dockerCompose.stopPolling()
}
}
+149
View File
@@ -0,0 +1,149 @@
import Foundation
/// Persists user settings and generates the .env file for Docker Compose.
class Config {
static let shared = Config()
// MARK: - Settings
var httpPort: Int = 7545
var p2pPort: Int = 7546
var publicURL: String = "http://localhost:7545"
var postgresPassword: String = "changeme"
var autoStartOnLaunch: Bool = false
var autoStartOnLogin: Bool = false
// Advanced
var chainRpcURL: String = ""
var contractNodeStaking: String = ""
var operatorPrivateKey: String = ""
var tigrisBucket: String = ""
var autoSync: Bool = false
var repoPathString: String = ""
// MARK: - Paths
/// Path to the gitlawb-node repo clone (for local builds).
var repoPath: URL {
if !repoPathString.isEmpty {
return URL(fileURLWithPath: repoPathString)
}
// Use compile-time source path to find the repo root.
// #filePath = .../gitlawb-node/macos-app/Sources/GitlawbNode/Config.swift
// repo root = 4 directories up from the source file
let sourceFile = URL(fileURLWithPath: #filePath)
let repoRoot = sourceFile
.deletingLastPathComponent() // Sources/GitlawbNode/
.deletingLastPathComponent() // Sources/
.deletingLastPathComponent() // macos-app/
.deletingLastPathComponent() // gitlawb-node/
let compose = repoRoot.appendingPathComponent("docker-compose.yml")
let dockerfile = repoRoot.appendingPathComponent("Dockerfile")
if FileManager.default.fileExists(atPath: compose.path) &&
FileManager.default.fileExists(atPath: dockerfile.path) {
return repoRoot
}
return dataDirectory
}
var dataDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("GitlawbNode")
}
private var configFilePath: URL {
dataDirectory.appendingPathComponent("config.json")
}
// MARK: - Init
private init() {
load()
}
// MARK: - Persistence
func persist() {
let dict: [String: Any] = [
"httpPort": httpPort,
"p2pPort": p2pPort,
"publicURL": publicURL,
"postgresPassword": postgresPassword,
"autoStartOnLaunch": autoStartOnLaunch,
"autoStartOnLogin": autoStartOnLogin,
"chainRpcURL": chainRpcURL,
"contractNodeStaking": contractNodeStaking,
"operatorPrivateKey": operatorPrivateKey,
"tigrisBucket": tigrisBucket,
"autoSync": autoSync,
"repoPath": repoPathString,
]
do {
try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
try data.write(to: configFilePath, options: .atomic)
} catch {
// Non-fatal — settings won't persist across restarts
}
}
private func load() {
guard let data = try? Data(contentsOf: configFilePath),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
httpPort = dict["httpPort"] as? Int ?? httpPort
p2pPort = dict["p2pPort"] as? Int ?? p2pPort
publicURL = dict["publicURL"] as? String ?? publicURL
postgresPassword = dict["postgresPassword"] as? String ?? postgresPassword
autoStartOnLaunch = dict["autoStartOnLaunch"] as? Bool ?? autoStartOnLaunch
autoStartOnLogin = dict["autoStartOnLogin"] as? Bool ?? autoStartOnLogin
chainRpcURL = dict["chainRpcURL"] as? String ?? chainRpcURL
contractNodeStaking = dict["contractNodeStaking"] as? String ?? contractNodeStaking
operatorPrivateKey = dict["operatorPrivateKey"] as? String ?? operatorPrivateKey
tigrisBucket = dict["tigrisBucket"] as? String ?? tigrisBucket
autoSync = dict["autoSync"] as? Bool ?? autoSync
repoPathString = dict["repoPath"] as? String ?? repoPathString
}
// MARK: - .env File Generation
/// Writes the .env file that Docker Compose reads.
func writeEnvFile() {
let envPath = dataDirectory.appendingPathComponent(".env")
var lines = [String]()
lines.append("# Generated by Gitlawb Node app — do not edit manually")
lines.append("POSTGRES_PASSWORD=\(postgresPassword)")
lines.append("GITLAWB_PUBLIC_URL=\(publicURL)")
lines.append("GITLAWB_PORT=\(httpPort)")
lines.append("GITLAWB_P2P_PORT=\(p2pPort)")
lines.append("GITLAWB_AUTO_SYNC=\(autoSync)")
if !chainRpcURL.isEmpty {
lines.append("GITLAWB_CHAIN_RPC_URL=\(chainRpcURL)")
}
if !contractNodeStaking.isEmpty {
lines.append("GITLAWB_CONTRACT_NODE_STAKING=\(contractNodeStaking)")
}
if !operatorPrivateKey.isEmpty {
lines.append("GITLAWB_OPERATOR_PRIVATE_KEY=\(operatorPrivateKey)")
}
if !tigrisBucket.isEmpty {
lines.append("GITLAWB_TIGRIS_BUCKET=\(tigrisBucket)")
}
lines.append("") // trailing newline
let content = lines.joined(separator: "\n")
do {
try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true)
try content.write(to: envPath, atomically: true, encoding: .utf8)
} catch {
// Non-fatal
}
}
}
@@ -0,0 +1,311 @@
import Foundation
/// Represents the aggregate state of the Docker Compose stack.
enum NodeStatus: Equatable {
case stopped
case starting
case running
case unhealthy
case error(String)
var label: String {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case .running: return "Running"
case .unhealthy: return "Unhealthy"
case .error(let msg): return "Error: \(msg)"
}
}
}
/// Manages the Docker Compose lifecycle for the gitlawb node stack.
class DockerCompose {
/// Current status, updated by polling.
private(set) var status: NodeStatus = .stopped {
didSet {
if oldValue != status {
onStatusChange?(status)
}
}
}
/// Called whenever status changes.
var onStatusChange: ((NodeStatus) -> Void)?
private var pollTimer: Timer?
private let pollInterval: TimeInterval = 5.0
/// Path to the docker-compose.yml — uses the repo's file if available, otherwise generates one.
var composeFilePath: String {
// Prefer the repo's own docker-compose.yml (supports `build: .`)
let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml")
if FileManager.default.fileExists(atPath: repoCompose.path) {
return repoCompose.path
}
// Fallback: generate a compose file with pre-built image
let userPath = Config.shared.dataDirectory.appendingPathComponent("docker-compose.yml").path
try? FileManager.default.createDirectory(at: Config.shared.dataDirectory, withIntermediateDirectories: true)
let content = Self.generateComposeFile()
try? content.write(toFile: userPath, atomically: true, encoding: .utf8)
return userPath
}
/// Project directory for docker compose (needed for `build: .` context).
var projectDirectory: String {
let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml")
if FileManager.default.fileExists(atPath: repoCompose.path) {
return Config.shared.repoPath.path
}
return Config.shared.dataDirectory.path
}
/// Path to the .env file with user configuration.
var envFilePath: String {
Config.shared.dataDirectory.appendingPathComponent(".env").path
}
// MARK: - Lifecycle
func start() {
guard let docker = DockerDetector.dockerPath() else {
status = .error("Docker not found")
return
}
status = .starting
Config.shared.writeEnvFile()
runAsync(docker: docker, arguments: [
"compose",
"--project-directory", projectDirectory,
"-f", composeFilePath,
"--env-file", envFilePath,
"up", "-d",
]) { [weak self] success, output in
DispatchQueue.main.async {
if success {
self?.refreshStatus()
} else {
self?.status = .error(output ?? "Failed to start")
}
}
}
}
func stop() {
guard let docker = DockerDetector.dockerPath() else {
status = .error("Docker not found")
return
}
runAsync(docker: docker, arguments: [
"compose",
"--project-directory", projectDirectory,
"-f", composeFilePath,
"--env-file", envFilePath,
"down",
]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.status = .stopped
}
}
}
func refreshStatus() {
guard let docker = DockerDetector.dockerPath() else {
status = .error("Docker not found")
return
}
let output = runSync(docker: docker, arguments: [
"compose",
"--project-directory", projectDirectory,
"-f", composeFilePath,
"--env-file", envFilePath,
"ps", "--format", "json",
])
guard let output = output, !output.isEmpty else {
status = .stopped
return
}
// docker compose ps --format json outputs one JSON object per line
let lines = output.components(separatedBy: .newlines).filter { !$0.isEmpty }
if lines.isEmpty {
status = .stopped
return
}
var allHealthy = true
var anyRunning = false
for line in lines {
guard let data = line.data(using: .utf8),
let container = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
continue
}
let state = (container["State"] as? String) ?? ""
let health = (container["Health"] as? String) ?? ""
if state == "running" {
anyRunning = true
if health == "unhealthy" {
allHealthy = false
}
}
}
if anyRunning && allHealthy {
status = .running
} else if anyRunning {
status = .unhealthy
} else {
status = .stopped
}
}
// MARK: - Polling
func startPolling() {
refreshStatus()
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.refreshStatus()
}
}
func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}
// MARK: - Process Helpers
private func runSync(docker: String, arguments: [String]) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: docker)
process.arguments = arguments
process.environment = processEnvironment()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
// Read concurrently to avoid pipe buffer deadlock
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(data: data, encoding: .utf8)
guard process.terminationStatus == 0 else {
return output // Return stderr output even on failure
}
return output
} catch {
return nil
}
}
private func runAsync(docker: String, arguments: [String], completion: @escaping (Bool, String?) -> Void) {
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: docker)
process.arguments = arguments
process.environment = self.processEnvironment()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
// Read all output first to prevent pipe buffer deadlock
// (docker compose build can produce >64KB of output)
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(data: data, encoding: .utf8)
completion(process.terminationStatus == 0, output)
} catch {
completion(false, error.localizedDescription)
}
}
}
private func processEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
// Ensure common Docker paths are in PATH
let extraPaths = "/usr/local/bin:/opt/homebrew/bin"
if let existing = env["PATH"] {
env["PATH"] = "\(extraPaths):\(existing)"
} else {
env["PATH"] = extraPaths
}
return env
}
// MARK: - Logs
/// Returns recent logs from the Docker Compose stack.
func logs(tail: Int = 200) -> String? {
guard let docker = DockerDetector.dockerPath() else { return nil }
return runSync(docker: docker, arguments: [
"compose",
"--project-directory", projectDirectory,
"-f", composeFilePath,
"--env-file", envFilePath,
"logs", "--tail", "\(tail)", "--no-color",
])
}
// MARK: - Compose File Generation
private static func generateComposeFile() -> String {
return """
# Generated by Gitlawb Node macOS app
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: gitlawb
POSTGRES_USER: gitlawb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitlawb"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
node:
image: ghcr.io/gitlawb/node:latest
depends_on:
postgres:
condition: service_healthy
ports:
- "${GITLAWB_HTTP_PORT:-7545}:7545"
- "${GITLAWB_P2P_PORT:-7546}:7546"
volumes:
- gitlawb-data:/data
environment:
DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb
GITLAWB_HOST: 0.0.0.0
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
GITLAWB_P2P_PORT: 7546
GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-}
GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-}
GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-}
GITLAWB_TIGRIS_BUCKET: ${GITLAWB_TIGRIS_BUCKET:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_ENDPOINT_URL_S3: ${AWS_ENDPOINT_URL_S3:-}
restart: unless-stopped
volumes:
pg-data:
gitlawb-data:
"""
}
}
@@ -0,0 +1,87 @@
import Foundation
/// Detects available Docker runtimes on macOS.
struct DockerDetector {
struct DockerRuntime {
let path: String
let name: String
let version: String
}
/// Well-known locations for docker binaries on macOS.
private static let searchPaths: [String] = [
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/Applications/Docker.app/Contents/Resources/bin/docker",
"\(NSHomeDirectory())/.orbstack/bin/docker",
"/Applications/OrbStack.app/Contents/MacOS/xbin/docker",
]
/// Finds the first available Docker binary and verifies the daemon is running.
static func detect() -> DockerRuntime? {
// Check PATH first, then well-known locations
let pathResult = run(command: "/usr/bin/which", arguments: ["docker"])
var candidates = [String]()
if let found = pathResult, !found.isEmpty {
candidates.append(found.trimmingCharacters(in: .whitespacesAndNewlines))
}
candidates.append(contentsOf: searchPaths)
for path in candidates {
guard FileManager.default.isExecutableFile(atPath: path) else { continue }
// Verify daemon is responding
if let version = dockerVersion(at: path) {
let name = runtimeName(for: path)
return DockerRuntime(path: path, name: name, version: version)
}
}
return nil
}
/// Returns the docker binary path or nil if unavailable.
static func dockerPath() -> String? {
detect()?.path
}
// MARK: - Private
private static func dockerVersion(at path: String) -> String? {
guard let output = run(command: path, arguments: ["version", "--format", "{{.Server.Version}}"]) else {
return nil
}
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func runtimeName(for path: String) -> String {
if path.contains("OrbStack") || path.contains(".orbstack") {
return "OrbStack"
} else if path.contains("Docker.app") || path == "/usr/local/bin/docker" {
return "Docker Desktop"
} else if path.contains("colima") {
return "Colima"
} else {
return "Docker"
}
}
private static func run(command: String, arguments: [String]) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: command)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)
} catch {
return nil
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Gitlawb Node</string>
<key>CFBundleDisplayName</key>
<string>Gitlawb Node</string>
<key>CFBundleIdentifier</key>
<string>com.gitlawb.node-app</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>GitlawbNode</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

@@ -0,0 +1,53 @@
# Run a gitlawb node + Postgres locally. For on-chain PoS operation, supply
# the operator key + contract addresses via a .env file or shell environment.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: gitlawb
POSTGRES_USER: gitlawb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitlawb"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
node:
build: .
depends_on:
postgres:
condition: service_healthy
ports:
- "${GITLAWB_PORT:-7545}:${GITLAWB_PORT:-7545}" # HTTP API + git smart-HTTP
- "${GITLAWB_P2P_PORT:-7546}:${GITLAWB_P2P_PORT:-7546}" # libp2p
volumes:
- gitlawb-data:/data
environment:
DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb
GITLAWB_HOST: 0.0.0.0
GITLAWB_PORT: ${GITLAWB_PORT:-7545}
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546}
# Sync
GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false}
# On-chain PoS (optional — leave unset for local/dev)
GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-}
GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-}
GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-}
GITLAWB_OPERATOR_STRICT_MODE: ${GITLAWB_OPERATOR_STRICT_MODE:-false}
GITLAWB_HEARTBEAT_INTERVAL_HOURS: ${GITLAWB_HEARTBEAT_INTERVAL_HOURS:-20}
# Optional shared storage (leave unset to use local disk only)
GITLAWB_TIGRIS_BUCKET: ${GITLAWB_TIGRIS_BUCKET:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_ENDPOINT_URL_S3: ${AWS_ENDPOINT_URL_S3:-}
restart: unless-stopped
volumes:
pg-data:
gitlawb-data:
@@ -0,0 +1,106 @@
import AppKit
import SwiftUI
/// Hosts the SwiftUI settings view in an NSWindow.
class SettingsWindowController: NSWindowController {
convenience init() {
let hostingController = NSHostingController(rootView: SettingsView())
let window = NSWindow(contentViewController: hostingController)
window.title = "Gitlawb Node Settings"
window.setContentSize(NSSize(width: 480, height: 280))
window.styleMask = [.titled, .closable, .miniaturizable]
window.center()
self.init(window: window)
}
}
struct SettingsView: View {
@State private var httpPort: String = String(Config.shared.httpPort)
@State private var p2pPort: String = String(Config.shared.p2pPort)
@State private var publicURL: String = Config.shared.publicURL
@State private var postgresPassword: String = Config.shared.postgresPassword
@State private var autoStartOnLaunch: Bool = Config.shared.autoStartOnLaunch
// Advanced (collapsed by default)
@State private var showAdvanced: Bool = false
@State private var chainRpcURL: String = Config.shared.chainRpcURL
@State private var contractNodeStaking: String = Config.shared.contractNodeStaking
@State private var operatorPrivateKey: String = Config.shared.operatorPrivateKey
@State private var tigrisBucket: String = Config.shared.tigrisBucket
@State private var autoSync: Bool = Config.shared.autoSync
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
Form {
TextField("HTTP Port:", text: $httpPort)
.textFieldStyle(.roundedBorder)
TextField("P2P Port:", text: $p2pPort)
.textFieldStyle(.roundedBorder)
TextField("Public URL:", text: $publicURL)
.textFieldStyle(.roundedBorder)
SecureField("Postgres Password:", text: $postgresPassword)
.textFieldStyle(.roundedBorder)
}
.formStyle(.columns)
Toggle("Auto-start node on app launch", isOn: $autoStartOnLaunch)
VStack(alignment: .leading, spacing: 2) {
Toggle("Sync repos from peers", isOn: $autoSync)
Text("Automatically replicate repositories from other nodes in the network")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
DisclosureGroup("Advanced (Operator & Storage)", isExpanded: $showAdvanced) {
Form {
TextField("Chain RPC URL:", text: $chainRpcURL)
.textFieldStyle(.roundedBorder)
TextField("Staking Contract:", text: $contractNodeStaking)
.textFieldStyle(.roundedBorder)
SecureField("Operator Private Key:", text: $operatorPrivateKey)
.textFieldStyle(.roundedBorder)
TextField("Tigris Bucket:", text: $tigrisBucket)
.textFieldStyle(.roundedBorder)
}
.formStyle(.columns)
.padding(.top, 4)
}
HStack {
Spacer()
Button("Save") {
save()
}
.keyboardShortcut(.defaultAction)
}
.padding(.top, 8)
}
.padding(20)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 440)
}
private func save() {
Config.shared.httpPort = Int(httpPort) ?? 7545
Config.shared.p2pPort = Int(p2pPort) ?? 7546
Config.shared.publicURL = publicURL
Config.shared.postgresPassword = postgresPassword
Config.shared.autoStartOnLaunch = autoStartOnLaunch
Config.shared.chainRpcURL = chainRpcURL
Config.shared.contractNodeStaking = contractNodeStaking
Config.shared.operatorPrivateKey = operatorPrivateKey
Config.shared.tigrisBucket = tigrisBucket
Config.shared.autoSync = autoSync
Config.shared.persist()
Config.shared.writeEnvFile()
// Close the window
NSApp.keyWindow?.close()
}
}
@@ -0,0 +1,249 @@
import AppKit
import ServiceManagement
class StatusBarController: NSObject {
private var statusItem: NSStatusItem
private var menu: NSMenu
private let dockerCompose: DockerCompose
private var startStopItem: NSMenuItem!
private var statusMenuItem: NSMenuItem!
private var settingsWindow: SettingsWindowController?
private var logsWindow: NSWindow?
init(dockerCompose: DockerCompose) {
self.dockerCompose = dockerCompose
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
menu = NSMenu()
super.init()
setupMenu()
updateIcon(for: .stopped)
statusItem.menu = menu
// Listen for status changes
dockerCompose.onStatusChange = { [weak self] status in
self?.updateIcon(for: status)
self?.updateMenuItems(for: status)
}
}
// MARK: - Menu Setup
private func setupMenu() {
statusMenuItem = NSMenuItem(title: "Status: Stopped", action: nil, keyEquivalent: "")
statusMenuItem.isEnabled = false
menu.addItem(statusMenuItem)
menu.addItem(.separator())
startStopItem = NSMenuItem(title: "Start Node", action: #selector(toggleNode), keyEquivalent: "s")
startStopItem.target = self
menu.addItem(startStopItem)
menu.addItem(.separator())
let openWebUI = NSMenuItem(title: "Open Web UI", action: #selector(openWebUI), keyEquivalent: "w")
openWebUI.target = self
menu.addItem(openWebUI)
let viewLogs = NSMenuItem(title: "View Logs…", action: #selector(viewLogs), keyEquivalent: "l")
viewLogs.target = self
menu.addItem(viewLogs)
let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(.separator())
let autoStartItem = NSMenuItem(title: "Start on Login", action: #selector(toggleAutoStart), keyEquivalent: "")
autoStartItem.target = self
autoStartItem.state = Config.shared.autoStartOnLogin ? .on : .off
menu.addItem(autoStartItem)
menu.addItem(.separator())
let quitItem = NSMenuItem(title: "Quit Gitlawb Node", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
}
// MARK: - Status Updates
private func updateIcon(for status: NodeStatus) {
guard let button = statusItem.button else { return }
let dotColor: NSColor
switch status {
case .running:
dotColor = .systemGreen
case .starting, .unhealthy:
dotColor = .systemYellow
case .stopped:
dotColor = .clear
case .error:
dotColor = .systemRed
}
// Load template image from bundle resources
let iconImage: NSImage
if let bundlePath = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"),
let img = NSImage(contentsOfFile: bundlePath) {
iconImage = img
} else {
// Fallback: try to load from @2x
if let bundlePath = Bundle.main.path(forResource: "MenuBarIcon@2x", ofType: "png"),
let img = NSImage(contentsOfFile: bundlePath) {
img.size = NSSize(width: 18, height: 18)
iconImage = img
} else {
// Last resort fallback to SF Symbol
iconImage = NSImage(systemSymbolName: "network", accessibilityDescription: "Gitlawb Node") ?? NSImage()
}
}
// Compose icon with status dot
let size = NSSize(width: 18, height: 18)
let composedImage = NSImage(size: size, flipped: false) { rect in
iconImage.draw(in: rect)
if dotColor != .clear {
let dotSize: CGFloat = 6
let dotRect = NSRect(x: rect.width - dotSize, y: 0, width: dotSize, height: dotSize)
dotColor.setFill()
NSBezierPath(ovalIn: dotRect).fill()
}
return true
}
composedImage.isTemplate = false // We handle tinting via the dot ourselves
button.image = composedImage
// Use template rendering for the base icon to adapt to light/dark menu bar
// But since we composite with a colored dot, disable template mode on final image
}
private func updateMenuItems(for status: NodeStatus) {
statusMenuItem.title = "Status: \(status.label)"
switch status {
case .running, .unhealthy:
startStopItem.title = "Stop Node"
case .starting:
startStopItem.title = "Starting…"
startStopItem.isEnabled = false
return
default:
startStopItem.title = "Start Node"
}
startStopItem.isEnabled = true
}
// MARK: - Actions
@objc private func toggleNode() {
switch dockerCompose.status {
case .running, .unhealthy:
dockerCompose.stop()
case .stopped, .error:
// Verify Docker is available before starting
guard DockerDetector.detect() != nil else {
showNoDockerAlert()
return
}
dockerCompose.start()
default:
break
}
}
@objc private func openWebUI() {
let port = Config.shared.httpPort
if let url = URL(string: "http://localhost:\(port)") {
NSWorkspace.shared.open(url)
}
}
@objc private func openSettings() {
if settingsWindow == nil {
settingsWindow = SettingsWindowController()
}
settingsWindow?.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func viewLogs() {
let logs = dockerCompose.logs() ?? "No logs available. Is the node running?"
let panel = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
)
panel.title = "Gitlawb Node Logs"
panel.isReleasedWhenClosed = false
panel.center()
self.logsWindow = panel
let scrollView = NSScrollView(frame: panel.contentView!.bounds)
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
let textView = NSTextView(frame: scrollView.bounds)
textView.autoresizingMask = [.width]
textView.isEditable = false
textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
textView.string = logs
textView.textContainerInset = NSSize(width: 8, height: 8)
scrollView.documentView = textView
panel.contentView = scrollView
// Scroll to bottom
textView.scrollToEndOfDocument(nil)
panel.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func toggleAutoStart(_ sender: NSMenuItem) {
let newState = !Config.shared.autoStartOnLogin
Config.shared.autoStartOnLogin = newState
sender.state = newState ? .on : .off
// Register/unregister with macOS login items
let service = SMAppService.mainApp
do {
if newState {
try service.register()
} else {
try service.unregister()
}
} catch {
// Silently fail — user can manage via System Settings
}
}
@objc private func quit() {
NSApp.terminate(nil)
}
// MARK: - Alerts
private func showNoDockerAlert() {
let alert = NSAlert()
alert.messageText = "Docker Not Found"
alert.informativeText = """
Gitlawb Node requires a Docker runtime to run.
Install one of the following:
• Docker Desktop (docker.com)
• OrbStack (orbstack.dev) — lightweight alternative
• Colima (github.com/abiosoft/colima) — free CLI-based
"""
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
+6
View File
@@ -0,0 +1,6 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the Gitlawb Node macOS menu bar app.
# Usage: ./scripts/build-macos-app.sh [--sign IDENTITY]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
APP_DIR="$ROOT_DIR/macos-app"
BUILD_DIR="$APP_DIR/.build/release"
OUTPUT_DIR="$ROOT_DIR/dist"
APP_NAME="Gitlawb Node"
APP_BUNDLE="$OUTPUT_DIR/${APP_NAME}.app"
SIGN_IDENTITY=""
while [[ $# -gt 0 ]]; do
case "$1" in
--sign)
SIGN_IDENTITY="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
echo "==> Building Gitlawb Node macOS app..."
# Ensure docker-compose.yml resource is up to date
mkdir -p "$APP_DIR/Sources/GitlawbNode/Resources"
cp "$ROOT_DIR/docker-compose.yml" "$APP_DIR/Sources/GitlawbNode/Resources/docker-compose.yml"
# Build with Swift Package Manager
cd "$APP_DIR"
swift build -c release
echo "==> Packaging .app bundle..."
# Create .app structure
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE/Contents/MacOS"
mkdir -p "$APP_BUNDLE/Contents/Resources"
# Copy binary
cp "$BUILD_DIR/GitlawbNode" "$APP_BUNDLE/Contents/MacOS/GitlawbNode"
# Copy Info.plist
cp "$APP_DIR/Sources/GitlawbNode/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
# Copy bundled resources
cp "$ROOT_DIR/docker-compose.yml" "$APP_BUNDLE/Contents/Resources/docker-compose.yml"
# Copy app icon
cp "$APP_DIR/Sources/GitlawbNode/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
# Copy menu bar icon
cp "$APP_DIR/Sources/GitlawbNode/Resources/MenuBarIcon.png" "$APP_BUNDLE/Contents/Resources/MenuBarIcon.png"
cp "$APP_DIR/Sources/GitlawbNode/Resources/[email protected]" "$APP_BUNDLE/Contents/Resources/[email protected]"
# Copy SPM-bundled resources if they exist
if [ -d "$BUILD_DIR/GitlawbNode_GitlawbNode.bundle" ]; then
cp -R "$BUILD_DIR/GitlawbNode_GitlawbNode.bundle" "$APP_BUNDLE/Contents/Resources/"
fi
echo "==> App bundle created: $APP_BUNDLE"
# Codesign if identity provided
if [ -n "$SIGN_IDENTITY" ]; then
echo "==> Signing with identity: $SIGN_IDENTITY"
codesign --force --deep --sign "$SIGN_IDENTITY" \
--options runtime \
--entitlements /dev/stdin <<EOF "$APP_BUNDLE"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
EOF
echo "==> Signed."
else
# Remove quarantine attribute so unsigned builds work without Gatekeeper prompts
echo "==> No signing identity provided — removing quarantine attribute for local use"
xattr -cr "$APP_BUNDLE"
fi
# Create DMG
echo "==> Creating DMG..."
DMG_PATH="$OUTPUT_DIR/GitlawbNode-macOS.dmg"
rm -f "$DMG_PATH"
# Create a temporary directory for DMG contents
DMG_STAGING="$OUTPUT_DIR/dmg-staging"
rm -rf "$DMG_STAGING"
mkdir -p "$DMG_STAGING"
cp -R "$APP_BUNDLE" "$DMG_STAGING/"
ln -s /Applications "$DMG_STAGING/Applications"
hdiutil create -volname "Gitlawb Node" \
-srcfolder "$DMG_STAGING" \
-ov -format UDZO \
"$DMG_PATH"
rm -rf "$DMG_STAGING"
echo "==> DMG created: $DMG_PATH"
echo "==> Done!"
echo " App: $APP_BUNDLE"
echo " DMG: $DMG_PATH"