mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a1cdd656 | ||
|
|
be099386e5 | ||
|
|
b09714af0b | ||
|
|
d09c23da86 | ||
|
|
f3df4a4dd4 | ||
|
|
034cb3c80c | ||
|
|
d2a440e521 | ||
|
|
f312955c35 | ||
|
|
ec4ef81770 |
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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-* \
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¬ify_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('/');
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -222,20 +222,23 @@ impl RepoStore {
|
||||
|
||||
/// Compute the local disk path and owner slug for a repo.
|
||||
///
|
||||
/// Rejects any input that could be used for path traversal — strict
|
||||
/// allowlist on both `owner_did` and `repo_name`, length-bounded, and
|
||||
/// the resulting path must remain inside `repos_dir`.
|
||||
/// 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(':', "_").replace('/', "_");
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
let local_path = self
|
||||
.repos_dir
|
||||
.join(&owner_slug)
|
||||
.join(format!("{repo_name}.git"));
|
||||
|
||||
// Defence in depth: even though both inputs are allowlisted, verify
|
||||
// the joined path is still rooted at repos_dir.
|
||||
if !local_path.starts_with(&self.repos_dir) {
|
||||
anyhow::bail!(
|
||||
"computed repo path escaped repos_dir: {}",
|
||||
@@ -243,6 +246,23 @@ impl RepoStore {
|
||||
);
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:-}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
Executable
+115
@@ -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"
|
||||
Reference in new issue
Block a user