Rust CLI for tangled

Compare changes

Choose any two refs to compare.

+18
.pre-commit-config.yaml
··· 1 + # Pre-commit configuration for Tangled workspace 2 + # Uses local hooks to avoid network fetches and to run with system toolchain 3 + 4 + repos: 5 + - repo: local 6 + hooks: 7 + - id: rustfmt 8 + name: rustfmt (cargo fmt --check) 9 + entry: cargo fmt --all -- --check 10 + language: system 11 + types: [rust] 12 + pass_filenames: false 13 + - id: clippy 14 + name: clippy (cargo clippy -D warnings) 15 + entry: bash -lc 'cargo clippy --all-targets -- -D warnings' 16 + language: system 17 + types: [rust] 18 + pass_filenames: false
+189 -292
AGENTS.md
··· 1 - # Tangled CLI – Agent Handoff (Massive Context) 1 + # Tangled CLI – Current Implementation Status 2 2 3 - This document is a complete handoff for the next Codex instance working on the Tangled CLI (Rust). It explains what exists, what to build next, where to edit, how to call the APIs, how to persist sessions, how to print output, and how to validate success. 3 + This document provides an overview of the Tangled CLI implementation status for AI agents or developers working on the project. 4 4 5 - Primary focus for this session: implement authentication (auth login/status/logout) and repository listing (repo list). 5 + ## Implementation Status 6 6 7 - -------------------------------------------------------------------------------- 7 + ### ✅ Fully Implemented 8 8 9 - ## 0) TL;DR – Immediate Actions 9 + #### Authentication (`auth`) 10 + - `login` - Authenticate with AT Protocol using `com.atproto.server.createSession` 11 + - `status` - Show current authentication status 12 + - `logout` - Clear stored session from keyring 10 13 11 - - Implement `auth login` using AT Protocol `com.atproto.server.createSession`. 12 - - Prompt for handle/password if flags aren’t provided. 13 - - POST to `/xrpc/com.atproto.server.createSession` at the configured PDS (default `https://bsky.social`). 14 - - Persist `{accessJwt, refreshJwt, did, handle}` via `SessionManager` (keyring-backed). 15 - - `auth status` reads keyring and prints handle + did; `auth logout` clears keyring. 14 + #### Repositories (`repo`) 15 + - `list` - List repositories using `com.atproto.repo.listRecords` with `collection=sh.tangled.repo` 16 + - `create` - Create repositories with two-step flow: 17 + 1. Create PDS record via `com.atproto.repo.createRecord` 18 + 2. Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth 19 + - `clone` - Clone repositories using libgit2 with SSH agent support 20 + - `info` - Display repository information including stats and languages 21 + - `delete` - Delete repositories (both PDS record and knot repo) 22 + - `star` / `unstar` - Star/unstar repositories via `sh.tangled.feed.star` 16 23 17 - - Implement `repo list` using Tangled’s repo list method (tentative `sh.tangled.repo.list`). 18 - - GET `/xrpc/sh.tangled.repo.list` with optional params: `user`, `knot`, `starred`. 19 - - Include `Authorization: Bearer <accessJwt>` if required. 20 - - Print results as table (default) or JSON (`--format json`). 24 + #### Issues (`issue`) 25 + - `list` - List issues via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.issue` 26 + - `create` - Create issues via `com.atproto.repo.createRecord` 27 + - `show` - Show issue details and comments 28 + - `edit` - Edit issue title, body, or state 29 + - `comment` - Add comments to issues 21 30 22 - Keep edits minimal and scoped to these features. 31 + #### Pull Requests (`pr`) 32 + - `list` - List PRs via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.pull` 33 + - `create` - Create PRs using `git format-patch` for patches 34 + - `show` - Show PR details and diff 35 + - `review` - Review PRs with approve/request-changes flags 36 + - `merge` - Merge PRs via `sh.tangled.repo.merge` with ServiceAuth 23 37 24 - -------------------------------------------------------------------------------- 38 + #### Knot Management (`knot`) 39 + - `migrate` - Migrate repositories between knots 40 + - Validates working tree is clean and pushed 41 + - Creates new repo on target knot with source seeding 42 + - Updates PDS record to point to new knot 25 43 26 - ## 1) Repository Map (Paths You Will Touch) 44 + #### Spindle CI/CD (`spindle`) 45 + - `config` - Enable/disable or configure spindle URL for a repository 46 + - Updates the `spindle` field in `sh.tangled.repo` record 47 + - `list` - List pipeline runs via `com.atproto.repo.listRecords` with `collection=sh.tangled.pipeline` 48 + - `logs` - Stream workflow logs via WebSocket (`wss://spindle.tangled.sh/spindle/logs/{knot}/{rkey}/{name}`) 49 + - `secret list` - List secrets via `sh.tangled.repo.listSecrets` with ServiceAuth 50 + - `secret add` - Add secrets via `sh.tangled.repo.addSecret` with ServiceAuth 51 + - `secret remove` - Remove secrets via `sh.tangled.repo.removeSecret` with ServiceAuth 27 52 28 - - CLI (binary): 29 - - `tangled/crates/tangled-cli/src/commands/auth.rs` → implement login/status/logout. 30 - - `tangled/crates/tangled-cli/src/commands/repo.rs` → implement list. 31 - - `tangled/crates/tangled-cli/src/cli.rs` → already contains arguments and subcommands; no structural changes needed. 32 - - `tangled/crates/tangled-cli/src/main.rs` → no change. 53 + ### 🚧 Partially Implemented / Stubs 33 54 34 - - Config + session: 35 - - `tangled/crates/tangled-config/src/session.rs` → already provides `Session` + `SessionManager` (keyring). 36 - - `tangled/crates/tangled-config/src/config.rs` → optional use for PDS/base URL (MVP can use CLI flags/env vars). 55 + #### Spindle CI/CD (`spindle`) 56 + - `run` - Manually trigger a workflow (stub) 57 + - **TODO**: Parse `.tangled.yml` to determine workflows 58 + - **TODO**: Create pipeline record and trigger spindle ingestion 59 + - **TODO**: Support manual trigger inputs 37 60 38 - - API client: 39 - - `tangled/crates/tangled-api/src/client.rs` → add XRPC helpers and implement `login_with_password` and `list_repos`. 61 + ## Architecture Overview 40 62 41 - -------------------------------------------------------------------------------- 63 + ### Workspace Structure 42 64 43 - ## 2) Current State Snapshot 65 + - `crates/tangled-cli` - CLI binary with clap-based argument parsing 66 + - `crates/tangled-config` - Configuration and keyring-backed session management 67 + - `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs 68 + - `crates/tangled-git` - Git operation helpers (currently unused) 44 69 45 - - Workspace is scaffolded and compiles after wiring dependencies (network needed to fetch crates): 46 - - `tangled-cli`: clap CLI with subcommands; commands currently log stubs. 47 - - `tangled-config`: TOML config loader/saver; keyring-backed session store. 48 - - `tangled-api`: client struct with placeholder methods. 49 - - `tangled-git`: stubs for future. 50 - - Placeholder lexicons in `tangled/lexicons/sh.tangled/*` are not authoritative; use AT Protocol docs and inspect real endpoints later. 70 + ### Key Patterns 51 71 52 - Goal: replace CLI stubs with real API calls for auth + repo list. 72 + #### ServiceAuth Flow 73 + Many Tangled API operations require ServiceAuth tokens: 74 + 1. Obtain token via `com.atproto.server.getServiceAuth` from PDS 75 + - `aud` parameter must be `did:web:<target-host>` 76 + - `exp` parameter should be Unix timestamp + 600 seconds 77 + 2. Use token as `Authorization: Bearer <serviceAuth>` for Tangled API calls 53 78 54 - -------------------------------------------------------------------------------- 79 + #### Repository Creation Flow 80 + Two-step process: 81 + 1. **PDS**: Create `sh.tangled.repo` record via `com.atproto.repo.createRecord` 82 + 2. **Tangled API**: Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth 55 83 56 - ## 3) Endpoints & Data Shapes 84 + #### Repository Listing 85 + Done entirely via PDS (not Tangled API): 86 + 1. Resolve handle → DID if needed via `com.atproto.identity.resolveHandle` 87 + 2. List records via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo` 88 + 3. Filter client-side (e.g., by knot) 57 89 58 - ### 3.1 AT Protocol – Create Session 90 + #### Pull Request Merging 91 + 1. Fetch PR record to get patch and target branch 92 + 2. Obtain ServiceAuth token 93 + 3. Call `sh.tangled.repo.merge` with `{did, name, patch, branch, commitMessage, commitBody}` 59 94 60 - - Method: `com.atproto.server.createSession` 61 - - HTTP: `POST /xrpc/com.atproto.server.createSession` 62 - - Request JSON: 63 - - `identifier: string` → user handle or email (e.g., `alice.bsky.social`). 64 - - `password: string` → password or app password. 65 - - Response JSON (subset used): 66 - - `accessJwt: string` 67 - - `refreshJwt: string` 68 - - `did: string` (e.g., `did:plc:...`) 69 - - `handle: string` 95 + ### Base URLs and Defaults 70 96 71 - Persist to keyring using `SessionManager`. 97 + - **PDS Base** (auth + record operations): Default `https://bsky.social`, stored in session 98 + - **Tangled API Base** (server operations): Default `https://tngl.sh`, can override via `TANGLED_API_BASE` 99 + - **Spindle Base** (CI/CD): Default `wss://spindle.tangled.sh` for WebSocket logs, can override via `TANGLED_SPINDLE_BASE` 72 100 73 - ### 3.2 Tangled – Repo List (tentative) 101 + ### Session Management 74 102 75 - - Method: `sh.tangled.repo.list` (subject to change; wire in a constant to adjust easily). 76 - - HTTP: `GET /xrpc/sh.tangled.repo.list?user=<..>&knot=<..>&starred=<true|false>` 77 - - Auth: likely required; include `Authorization: Bearer <accessJwt>`. 78 - - Response JSON (envelope): 79 - - `{ "repos": [{ "name": string, "knot": string, "private": bool, ... }] }` 103 + Sessions are stored in the system keyring: 104 + - Linux: GNOME Keyring / KWallet via Secret Service API 105 + - macOS: macOS Keychain 106 + - Windows: Windows Credential Manager 80 107 81 - If method name or response shape differs, adapt the client code; keep CLI interface stable. 108 + Session includes: 109 + ```rust 110 + struct Session { 111 + access_jwt: String, 112 + refresh_jwt: String, 113 + did: String, 114 + handle: String, 115 + pds: Option<String>, // PDS base URL 116 + } 117 + ``` 82 118 83 - -------------------------------------------------------------------------------- 119 + ## Working with tangled-core 84 120 85 - ## 4) Implementation Plan 121 + The `../tangled-core` repository contains the server implementation and lexicon definitions. 86 122 87 - ### 4.1 Add XRPC helpers and methods in `tangled-api` 123 + ### Key Files to Check 88 124 89 - File: `tangled/crates/tangled-api/src/client.rs` 125 + - **Lexicons**: `../tangled-core/lexicons/**/*.json` 126 + - Defines XRPC method schemas (NSIDs, parameters, responses) 127 + - Example: `sh.tangled.repo.create`, `sh.tangled.repo.merge` 90 128 91 - - Extend `TangledClient` with: 92 - - `fn xrpc_url(&self, method: &str) -> String` → combines `base_url` + `/xrpc/` + `method`. 93 - - `async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(&self, method, req, bearer) -> Result<TRes>`. 94 - - `async fn get_json<TRes: DeserializeOwned>(&self, method, params, bearer) -> Result<TRes>`. 95 - - Include `Authorization: Bearer <token>` when `bearer` is provided. 129 + - **XRPC Routes**: `../tangled-core/knotserver/xrpc/xrpc.go` 130 + - Shows which endpoints require ServiceAuth 131 + - Maps NSIDs to handler functions 96 132 97 - - Implement: 98 - - `pub async fn login_with_password(&self, handle: &str, password: &str, pds: &str) -> Result<Session>` 99 - - POST to `com.atproto.server.createSession` at `self.base_url` (which should be the PDS base). 100 - - Map response to `tangled_config::session::Session` and return it (caller will persist). 101 - - `pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>>` 102 - - GET `sh.tangled.repo.list` with params present only if set. 103 - - Return parsed `Vec<Repository>` from an envelope `{ repos: [...] }`. 133 + - **API Handlers**: `../tangled-core/knotserver/xrpc/*.go` 134 + - Implementation details for server-side operations 135 + - Example: `create_repo.go`, `merge.go` 104 136 105 - Error handling: For non-2xx, read the response body, return `anyhow!("{status}: {body}")`. 137 + ### Useful Search Commands 106 138 107 - ### 4.2 Wire CLI auth commands 139 + ```bash 140 + # Find a specific NSID 141 + rg -n "sh\.tangled\.repo\.create" ../tangled-core 108 142 109 - File: `tangled/crates/tangled-cli/src/commands/auth.rs` 143 + # List all lexicons 144 + ls ../tangled-core/lexicons/repo 110 145 111 - - `login`: 112 - - Determine PDS: use `--pds` arg if provided, else default `https://bsky.social` (later from config/env). 113 - - Prompt for missing handle/password. 114 - - `let client = tangled_api::TangledClient::new(&pds);` 115 - - `let session = client.login_with_password(&handle, &password, &pds).await?;` 116 - - `tangled_config::session::SessionManager::default().save(&session)?;` 117 - - Print: `Logged in as '{handle}' ({did})`. 146 + # Check ServiceAuth usage 147 + rg -n "ServiceAuth|VerifyServiceAuth" ../tangled-core 148 + ``` 118 149 119 - - `status`: 120 - - Load `SessionManager::default().load()?`. 121 - - If Some: print `Logged in as '{handle}' ({did})`. 122 - - Else: print `Not logged in. Run: tangled auth login`. 150 + ## Next Steps for Contributors 123 151 124 - - `logout`: 125 - - `SessionManager::default().clear()?`. 126 - - Print `Logged out` if something was cleared; otherwise `No session found` is acceptable. 152 + ### Priority: Implement `spindle run` 127 153 128 - ### 4.3 Wire CLI repo list 154 + The only remaining stub is `spindle run` for manually triggering workflows. Implementation plan: 129 155 130 - File: `tangled/crates/tangled-cli/src/commands/repo.rs` 156 + 1. **Parse `.tangled.yml`** in the current repository to extract workflow definitions 157 + - Look for workflow names, triggers, and manual trigger inputs 131 158 132 - - Load session; if absent, print `Please login first: tangled auth login` and exit 1 (or 0 with friendly message; choose one and be consistent). 133 - - Build a client for Tangled API base (for now, default to `https://tangled.org` or allow `TANGLED_API_BASE` env var to override): 134 - - `let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into());` 135 - - `let client = tangled_api::TangledClient::new(base);` 136 - - Call `client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?`. 137 - - Print: 138 - - If `Cli.format == OutputFormat::Json`: `serde_json::to_string_pretty(&repos)`. 139 - - Else: simple columns `NAME KNOT PRIVATE` using `println!` formatting for now. 159 + 2. **Create pipeline record** on PDS via `com.atproto.repo.createRecord`: 160 + ```rust 161 + collection: "sh.tangled.pipeline" 162 + record: { 163 + triggerMetadata: { 164 + kind: "manual", 165 + repo: { knot, did, repo, defaultBranch }, 166 + manual: { inputs: [...] } 167 + }, 168 + workflows: [{ name, engine, clone, raw }] 169 + } 170 + ``` 140 171 141 - -------------------------------------------------------------------------------- 172 + 3. **Notify spindle** (if needed) or let the ingester pick up the new record 142 173 143 - ## 5) Code Snippets (Copy/Paste Friendly) 174 + 4. **Support workflow selection** when multiple workflows exist: 175 + - `--workflow <name>` flag to select specific workflow 176 + - Default to first workflow if not specified 144 177 145 - ### 5.1 In `tangled-api/src/client.rs` 178 + 5. **Support manual inputs** (if workflow defines them): 179 + - Prompt for input values or accept via flags 146 180 147 - ```rust 148 - use anyhow::{anyhow, bail, Result}; 149 - use serde::{de::DeserializeOwned, Deserialize, Serialize}; 150 - use tangled_config::session::Session; 181 + ### Code Quality Tasks 151 182 152 - #[derive(Clone, Debug)] 153 - pub struct TangledClient { pub(crate) base_url: String } 154 - 155 - impl TangledClient { 156 - pub fn new(base_url: impl Into<String>) -> Self { Self { base_url: base_url.into() } } 157 - pub fn default() -> Self { Self::new("https://tangled.org") } 158 - 159 - fn xrpc_url(&self, method: &str) -> String { 160 - format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 161 - } 183 + - Add more comprehensive error messages for common failure cases 184 + - Improve table formatting for list commands (consider using `tabled` crate features) 185 + - Add shell completion generation (bash, zsh, fish) 186 + - Add more unit tests with `mockito` for API client methods 187 + - Add integration tests with `assert_cmd` for CLI commands 162 188 163 - async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( 164 - &self, 165 - method: &str, 166 - req: &TReq, 167 - bearer: Option<&str>, 168 - ) -> Result<TRes> { 169 - let url = self.xrpc_url(method); 170 - let client = reqwest::Client::new(); 171 - let mut reqb = client.post(url).header(reqwest::header::CONTENT_TYPE, "application/json"); 172 - if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } 173 - let res = reqb.json(req).send().await?; 174 - let status = res.status(); 175 - if !status.is_success() { 176 - let body = res.text().await.unwrap_or_default(); 177 - return Err(anyhow!("{}: {}", status, body)); 178 - } 179 - Ok(res.json::<TRes>().await?) 180 - } 189 + ### Documentation Tasks 181 190 182 - async fn get_json<TRes: DeserializeOwned>( 183 - &self, 184 - method: &str, 185 - params: &[(&str, String)], 186 - bearer: Option<&str>, 187 - ) -> Result<TRes> { 188 - let url = self.xrpc_url(method); 189 - let client = reqwest::Client::new(); 190 - let mut reqb = client.get(url).query(&params); 191 - if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } 192 - let res = reqb.send().await?; 193 - let status = res.status(); 194 - if !status.is_success() { 195 - let body = res.text().await.unwrap_or_default(); 196 - return Err(anyhow!("{}: {}", status, body)); 197 - } 198 - Ok(res.json::<TRes>().await?) 199 - } 191 + - Add man pages for all commands 192 + - Create video tutorials for common workflows 193 + - Add troubleshooting guide for common issues 200 194 201 - pub async fn login_with_password(&self, handle: &str, password: &str, _pds: &str) -> Result<Session> { 202 - #[derive(Serialize)] 203 - struct Req<'a> { #[serde(rename = "identifier")] identifier: &'a str, #[serde(rename = "password")] password: &'a str } 204 - #[derive(Deserialize)] 205 - struct Res { #[serde(rename = "accessJwt")] access_jwt: String, #[serde(rename = "refreshJwt")] refresh_jwt: String, did: String, handle: String } 206 - let body = Req { identifier: handle, password }; 207 - let res: Res = self.post_json("com.atproto.server.createSession", &body, None).await?; 208 - Ok(Session { access_jwt: res.access_jwt, refresh_jwt: res.refresh_jwt, did: res.did, handle: res.handle, ..Default::default() }) 209 - } 195 + ## Development Workflow 210 196 211 - pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>> { 212 - #[derive(Deserialize)] 213 - struct Envelope { repos: Vec<Repository> } 214 - let mut q = vec![]; 215 - if let Some(u) = user { q.push(("user", u.to_string())); } 216 - if let Some(k) = knot { q.push(("knot", k.to_string())); } 217 - if starred { q.push(("starred", true.to_string())); } 218 - let env: Envelope = self.get_json("sh.tangled.repo.list", &q, bearer).await?; 219 - Ok(env.repos) 220 - } 221 - } 197 + ### Building 222 198 223 - #[derive(Debug, Clone, Serialize, Deserialize, Default)] 224 - pub struct Repository { pub did: Option<String>, pub rkey: Option<String>, pub name: String, pub knot: Option<String>, pub description: Option<String>, pub private: bool } 199 + ```sh 200 + cargo build # Debug build 201 + cargo build --release # Release build 225 202 ``` 226 203 227 - ### 5.2 In `tangled-cli/src/commands/auth.rs` 228 - 229 - ```rust 230 - use anyhow::Result; 231 - use dialoguer::{Input, Password}; 232 - use tangled_config::session::SessionManager; 233 - use crate::cli::{AuthCommand, AuthLoginArgs, Cli}; 234 - 235 - pub async fn run(_cli: &Cli, cmd: AuthCommand) -> Result<()> { 236 - match cmd { 237 - AuthCommand::Login(args) => login(args).await, 238 - AuthCommand::Status => status().await, 239 - AuthCommand::Logout => logout().await, 240 - } 241 - } 242 - 243 - async fn login(mut args: AuthLoginArgs) -> Result<()> { 244 - let handle: String = match args.handle.take() { Some(h) => h, None => Input::new().with_prompt("Handle").interact_text()? }; 245 - let password: String = match args.password.take() { Some(p) => p, None => Password::new().with_prompt("Password").interact()? }; 246 - let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string()); 247 - let client = tangled_api::TangledClient::new(&pds); 248 - let session = client.login_with_password(&handle, &password, &pds).await?; 249 - SessionManager::default().save(&session)?; 250 - println!("Logged in as '{}' ({})", session.handle, session.did); 251 - Ok(()) 252 - } 253 - 254 - async fn status() -> Result<()> { 255 - let mgr = SessionManager::default(); 256 - match mgr.load()? { 257 - Some(s) => println!("Logged in as '{}' ({})", s.handle, s.did), 258 - None => println!("Not logged in. Run: tangled auth login"), 259 - } 260 - Ok(()) 261 - } 204 + ### Running 262 205 263 - async fn logout() -> Result<()> { 264 - let mgr = SessionManager::default(); 265 - if mgr.load()?.is_some() { mgr.clear()?; println!("Logged out"); } else { println!("No session found"); } 266 - Ok(()) 267 - } 206 + ```sh 207 + cargo run -p tangled-cli -- <command> 268 208 ``` 269 209 270 - ### 5.3 In `tangled-cli/src/commands/repo.rs` 271 - 272 - ```rust 273 - use anyhow::{anyhow, Result}; 274 - use tangled_config::session::SessionManager; 275 - use crate::cli::{Cli, RepoCommand, RepoListArgs}; 276 - 277 - pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> { 278 - match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) } 279 - } 210 + ### Testing 280 211 281 - async fn list(args: RepoListArgs) -> Result<()> { 282 - let mgr = SessionManager::default(); 283 - let session = mgr.load()?.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 284 - let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into()); 285 - let client = tangled_api::TangledClient::new(base); 286 - let repos = client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?; 287 - // Simple output: table or JSON to be improved later 288 - println!("NAME\tKNOT\tPRIVATE"); 289 - for r in repos { println!("{}\t{}\t{}", r.name, r.knot.unwrap_or_default(), r.private); } 290 - Ok(()) 291 - } 212 + ```sh 213 + cargo test # Run all tests 214 + cargo test -- --nocapture # Show println output 292 215 ``` 293 216 294 - -------------------------------------------------------------------------------- 295 - 296 - ## 6) Configuration, Env Vars, and Security 297 - 298 - - PDS base (auth): default `https://bsky.social`. Accept CLI flag `--pds`; later read from config. 299 - - Tangled API base (repo list): default `https://tangled.org`; allow override via `TANGLED_API_BASE` env var. 300 - - Do not log passwords or tokens. 301 - - Store tokens only in keyring (already implemented). 302 - 303 - -------------------------------------------------------------------------------- 304 - 305 - ## 7) Testing Plan (MVP) 306 - 307 - - Client unit tests with `mockito` for `createSession` and `repo list` endpoints; simulate expected JSON. 308 - - CLI smoke tests optional for this pass. If added, use `assert_cmd` to check printed output strings. 309 - - Avoid live network calls in tests. 310 - 311 - -------------------------------------------------------------------------------- 312 - 313 - ## 8) Acceptance Criteria 314 - 315 - - `tangled auth login`: 316 - - Prompts or uses flags; successful call saves session and prints `Logged in as ...`. 317 - - On failure, shows HTTP status and short message. 318 - - `tangled auth status`: 319 - - Shows handle + did if session exists; otherwise says not logged in. 320 - - `tangled auth logout`: 321 - - Clears keyring; prints confirmation. 322 - - `tangled repo list`: 323 - - Performs authenticated GET and prints a list (even if empty) without panicking. 324 - - JSON output possible later; table output acceptable for now. 217 + ### Code Quality 325 218 326 - -------------------------------------------------------------------------------- 219 + ```sh 220 + cargo fmt # Format code 221 + cargo clippy # Run linter 222 + cargo clippy -- -W clippy::all # Strict linting 223 + ``` 327 224 328 - ## 9) Troubleshooting Notes 329 - 330 - - Keyring errors on Linux may indicate no secret service running; suggest enabling GNOME Keyring or KWallet. 331 - - If `repo list` returns 404, the method name or base URL may be wrong; adjust `sh.tangled.repo.list` or `TANGLED_API_BASE`. 332 - - If 401, session may be missing/expired; run `auth login` again. 225 + ## Troubleshooting Common Issues 333 226 334 - -------------------------------------------------------------------------------- 227 + ### Keyring Errors on Linux 335 228 336 - ## 10) Non‑Goals for This Pass 229 + Ensure a secret service is running: 230 + ```sh 231 + systemctl --user enable --now gnome-keyring-daemon 232 + ``` 337 233 338 - - Refresh token flow, device code, OAuth. 339 - - PRs, issues, knots, spindle implementation. 340 - - Advanced formatting, paging, completions. 234 + ### Invalid Token Errors 341 235 342 - -------------------------------------------------------------------------------- 236 + - For record operations: Use PDS client, not Tangled API client 237 + - For server operations: Ensure ServiceAuth audience DID matches target host 343 238 344 - ## 11) Future Follow‑ups 239 + ### Repository Not Found 345 240 346 - - Refresh flow (`com.atproto.server.refreshSession`) and retry once on 401. 347 - - Persist base URLs and profiles in config; add `tangled config` commands. 348 - - Proper table/json formatting and shell completions. 241 + - Verify repo exists: `tangled repo info owner/name` 242 + - Check you're using the correct owner (handle or DID) 243 + - Ensure you have access permissions 349 244 350 - -------------------------------------------------------------------------------- 245 + ### WebSocket Connection Failures 351 246 352 - ## 12) Quick Operator Commands 247 + - Check spindle base URL is correct (default: `wss://spindle.tangled.sh`) 248 + - Verify the job_id format: `knot:rkey:name` 249 + - Ensure the workflow has actually run and has logs 353 250 354 - - Build CLI: `cargo build -p tangled-cli` 355 - - Help: `cargo run -p tangled-cli -- --help` 356 - - Login: `cargo run -p tangled-cli -- auth login --handle <handle>` 357 - - Status: `cargo run -p tangled-cli -- auth status` 358 - - Repo list: `TANGLED_API_BASE=https://tangled.org cargo run -p tangled-cli -- repo list --user <handle>` 251 + ## Additional Resources 359 252 360 - -------------------------------------------------------------------------------- 253 + - Main README: `README.md` - User-facing documentation 254 + - Getting Started Guide: `docs/getting-started.md` - Tutorial for new users 255 + - Lexicons: `../tangled-core/lexicons/` - XRPC method definitions 256 + - Server Implementation: `../tangled-core/knotserver/` - Server-side code 361 257 362 - End of handoff. Implement auth login and repo list as described, keeping changes focused and testable. 258 + --- 363 259 260 + Last updated: 2025-10-14
+156 -4
Cargo.lock
··· 204 204 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 205 205 206 206 [[package]] 207 + name = "block-buffer" 208 + version = "0.10.4" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 211 + dependencies = [ 212 + "generic-array", 213 + ] 214 + 215 + [[package]] 207 216 name = "bumpalo" 208 217 version = "3.19.0" 209 218 source = "registry+https://github.com/rust-lang/crates.io-index" 210 219 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 220 + 221 + [[package]] 222 + name = "byteorder" 223 + version = "1.5.0" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 211 226 212 227 [[package]] 213 228 name = "bytes" ··· 388 403 ] 389 404 390 405 [[package]] 406 + name = "cpufeatures" 407 + version = "0.2.17" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 410 + dependencies = [ 411 + "libc", 412 + ] 413 + 414 + [[package]] 391 415 name = "crc32fast" 392 416 version = "1.5.0" 393 417 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 397 421 ] 398 422 399 423 [[package]] 424 + name = "crypto-common" 425 + version = "0.1.6" 426 + source = "registry+https://github.com/rust-lang/crates.io-index" 427 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 428 + dependencies = [ 429 + "generic-array", 430 + "typenum", 431 + ] 432 + 433 + [[package]] 400 434 name = "data-encoding" 401 435 version = "2.9.0" 402 436 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 455 489 "tempfile", 456 490 "thiserror 1.0.69", 457 491 "zeroize", 492 + ] 493 + 494 + [[package]] 495 + name = "digest" 496 + version = "0.10.7" 497 + source = "registry+https://github.com/rust-lang/crates.io-index" 498 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 499 + dependencies = [ 500 + "block-buffer", 501 + "crypto-common", 458 502 ] 459 503 460 504 [[package]] ··· 631 675 "pin-project-lite", 632 676 "pin-utils", 633 677 "slab", 678 + ] 679 + 680 + [[package]] 681 + name = "generic-array" 682 + version = "0.14.9" 683 + source = "registry+https://github.com/rust-lang/crates.io-index" 684 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 685 + dependencies = [ 686 + "typenum", 687 + "version_check", 634 688 ] 635 689 636 690 [[package]] ··· 1472 1526 "bytes", 1473 1527 "getrandom 0.3.3", 1474 1528 "lru-slab", 1475 - "rand", 1529 + "rand 0.9.2", 1476 1530 "ring", 1477 1531 "rustc-hash", 1478 1532 "rustls", ··· 1515 1569 1516 1570 [[package]] 1517 1571 name = "rand" 1572 + version = "0.8.5" 1573 + source = "registry+https://github.com/rust-lang/crates.io-index" 1574 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1575 + dependencies = [ 1576 + "libc", 1577 + "rand_chacha 0.3.1", 1578 + "rand_core 0.6.4", 1579 + ] 1580 + 1581 + [[package]] 1582 + name = "rand" 1518 1583 version = "0.9.2" 1519 1584 source = "registry+https://github.com/rust-lang/crates.io-index" 1520 1585 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1521 1586 dependencies = [ 1522 - "rand_chacha", 1523 - "rand_core", 1587 + "rand_chacha 0.9.0", 1588 + "rand_core 0.9.3", 1589 + ] 1590 + 1591 + [[package]] 1592 + name = "rand_chacha" 1593 + version = "0.3.1" 1594 + source = "registry+https://github.com/rust-lang/crates.io-index" 1595 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1596 + dependencies = [ 1597 + "ppv-lite86", 1598 + "rand_core 0.6.4", 1524 1599 ] 1525 1600 1526 1601 [[package]] ··· 1530 1605 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1531 1606 dependencies = [ 1532 1607 "ppv-lite86", 1533 - "rand_core", 1608 + "rand_core 0.9.3", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "rand_core" 1613 + version = "0.6.4" 1614 + source = "registry+https://github.com/rust-lang/crates.io-index" 1615 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1616 + dependencies = [ 1617 + "getrandom 0.2.16", 1534 1618 ] 1535 1619 1536 1620 [[package]] ··· 1851 1935 ] 1852 1936 1853 1937 [[package]] 1938 + name = "sha1" 1939 + version = "0.10.6" 1940 + source = "registry+https://github.com/rust-lang/crates.io-index" 1941 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1942 + dependencies = [ 1943 + "cfg-if", 1944 + "cpufeatures", 1945 + "digest", 1946 + ] 1947 + 1948 + [[package]] 1854 1949 name = "shell-words" 1855 1950 version = "1.1.0" 1856 1951 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1994 2089 version = "0.1.0" 1995 2090 dependencies = [ 1996 2091 "anyhow", 2092 + "chrono", 1997 2093 "clap", 1998 2094 "colored", 1999 2095 "dialoguer", 2096 + "futures-util", 2097 + "git2", 2000 2098 "indicatif", 2001 2099 "serde", 2002 2100 "serde_json", ··· 2004 2102 "tangled-config", 2005 2103 "tangled-git", 2006 2104 "tokio", 2105 + "tokio-tungstenite", 2106 + "url", 2007 2107 ] 2008 2108 2009 2109 [[package]] ··· 2167 2267 ] 2168 2268 2169 2269 [[package]] 2270 + name = "tokio-tungstenite" 2271 + version = "0.21.0" 2272 + source = "registry+https://github.com/rust-lang/crates.io-index" 2273 + checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" 2274 + dependencies = [ 2275 + "futures-util", 2276 + "log", 2277 + "native-tls", 2278 + "tokio", 2279 + "tokio-native-tls", 2280 + "tungstenite", 2281 + ] 2282 + 2283 + [[package]] 2170 2284 name = "tokio-util" 2171 2285 version = "0.7.16" 2172 2286 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2302 2416 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2303 2417 2304 2418 [[package]] 2419 + name = "tungstenite" 2420 + version = "0.21.0" 2421 + source = "registry+https://github.com/rust-lang/crates.io-index" 2422 + checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" 2423 + dependencies = [ 2424 + "byteorder", 2425 + "bytes", 2426 + "data-encoding", 2427 + "http", 2428 + "httparse", 2429 + "log", 2430 + "native-tls", 2431 + "rand 0.8.5", 2432 + "sha1", 2433 + "thiserror 1.0.69", 2434 + "url", 2435 + "utf-8", 2436 + ] 2437 + 2438 + [[package]] 2439 + name = "typenum" 2440 + version = "1.19.0" 2441 + source = "registry+https://github.com/rust-lang/crates.io-index" 2442 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2443 + 2444 + [[package]] 2305 2445 name = "unicase" 2306 2446 version = "2.8.1" 2307 2447 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2344 2484 ] 2345 2485 2346 2486 [[package]] 2487 + name = "utf-8" 2488 + version = "0.7.6" 2489 + source = "registry+https://github.com/rust-lang/crates.io-index" 2490 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2491 + 2492 + [[package]] 2347 2493 name = "utf8_iter" 2348 2494 version = "1.0.4" 2349 2495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2360 2506 version = "0.2.15" 2361 2507 source = "registry+https://github.com/rust-lang/crates.io-index" 2362 2508 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2509 + 2510 + [[package]] 2511 + name = "version_check" 2512 + version = "0.9.5" 2513 + source = "registry+https://github.com/rust-lang/crates.io-index" 2514 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2363 2515 2364 2516 [[package]] 2365 2517 name = "want"
+4
Cargo.toml
··· 53 53 base64 = "0.22" 54 54 regex = "1.10" 55 55 56 + # WebSocket 57 + tokio-tungstenite = { version = "0.21", features = ["native-tls"] } 58 + futures-util = "0.3" 59 + 56 60 # Testing 57 61 mockito = "1.4" 58 62 tempfile = "3.10"
+173 -16
README.md
··· 1 - # Tangled CLI (Rust) 1 + # Tangled CLI 2 2 3 3 A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol. 4 4 5 - Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring. 5 + ## Features 6 + 7 + Tangled CLI is a fully functional tool for managing repositories, issues, pull requests, and CI/CD workflows on the Tangled platform. 8 + 9 + ### Implemented Commands 10 + 11 + - **Authentication** (`auth`) 12 + - `login` - Authenticate with AT Protocol credentials 13 + - `status` - Show current authentication status 14 + - `logout` - Clear stored session 15 + 16 + - **Repositories** (`repo`) 17 + - `list` - List your repositories or another user's repos 18 + - `create` - Create a new repository on a knot 19 + - `clone` - Clone a repository to your local machine 20 + - `info` - Show detailed repository information 21 + - `delete` - Delete a repository 22 + - `star` / `unstar` - Star or unstar repositories 23 + 24 + - **Issues** (`issue`) 25 + - `list` - List issues for a repository 26 + - `create` - Create a new issue 27 + - `show` - Show issue details and comments 28 + - `edit` - Edit issue title, body, or state 29 + - `comment` - Add a comment to an issue 30 + 31 + - **Pull Requests** (`pr`) 32 + - `list` - List pull requests for a repository 33 + - `create` - Create a pull request from a branch 34 + - `show` - Show pull request details and diff 35 + - `review` - Review a pull request (approve/request changes) 36 + - `merge` - Merge a pull request 37 + 38 + - **Knot Management** (`knot`) 39 + - `migrate` - Migrate a repository to another knot 40 + 41 + - **CI/CD with Spindle** (`spindle`) 42 + - `config` - Enable/disable or configure spindle for a repository 43 + - `list` - List pipeline runs for a repository 44 + - `logs` - Stream logs from a workflow execution 45 + - `secret` - Manage secrets for CI/CD workflows 46 + - `list` - List secrets for a repository 47 + - `add` - Add or update a secret 48 + - `remove` - Remove a secret 49 + - `run` - Manually trigger a workflow (not yet implemented) 50 + 51 + ## Installation 52 + 53 + ### Build from Source 54 + 55 + Requires Rust toolchain (1.70+) and network access to fetch dependencies. 56 + 57 + ```sh 58 + cargo build --release 59 + ``` 60 + 61 + The binary will be available at `target/release/tangled-cli`. 62 + 63 + ### Install from AUR (Arch Linux) 64 + 65 + Community-maintained package: 66 + 67 + ```sh 68 + yay -S tangled-cli-git 69 + ``` 70 + 71 + ## Quick Start 72 + 73 + 1. **Login to Tangled**: 74 + ```sh 75 + tangled auth login --handle your.handle.bsky.social 76 + ``` 77 + 78 + 2. **List your repositories**: 79 + ```sh 80 + tangled repo list 81 + ``` 82 + 83 + 3. **Create a new repository**: 84 + ```sh 85 + tangled repo create myproject --description "My cool project" 86 + ``` 87 + 88 + 4. **Clone a repository**: 89 + ```sh 90 + tangled repo clone username/reponame 91 + ``` 92 + 93 + ## Workspace Structure 94 + 95 + - `crates/tangled-cli` - CLI binary with clap-based argument parsing 96 + - `crates/tangled-config` - Configuration and session management (keyring-backed) 97 + - `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs 98 + - `crates/tangled-git` - Git operation helpers 6 99 7 - ## Workspace 100 + ## Configuration 8 101 9 - - `crates/tangled-cli`: CLI binary (clap-based) 10 - - `crates/tangled-config`: Config + session management 11 - - `crates/tangled-api`: XRPC client wrapper (stubs) 12 - - `crates/tangled-git`: Git helpers (stubs) 13 - - `lexicons/sh.tangled`: Placeholder lexicons 102 + The CLI stores session credentials securely in your system keyring and configuration in: 103 + - Linux: `~/.config/tangled/config.toml` 104 + - macOS: `~/Library/Application Support/tangled/config.toml` 105 + - Windows: `%APPDATA%\tangled\config.toml` 106 + 107 + ### Environment Variables 108 + 109 + - `TANGLED_PDS_BASE` - Override the PDS base URL (default: `https://bsky.social`) 110 + - `TANGLED_API_BASE` - Override the Tangled API base URL (default: `https://tngl.sh`) 111 + - `TANGLED_SPINDLE_BASE` - Override the Spindle base URL (default: `wss://spindle.tangled.sh`) 112 + 113 + ## Examples 114 + 115 + ### Working with Issues 116 + 117 + ```sh 118 + # Create an issue 119 + tangled issue create --repo myrepo --title "Bug: Fix login" --body "Description here" 14 120 15 - ## Quick start 121 + # List issues 122 + tangled issue list --repo myrepo 16 123 124 + # Comment on an issue 125 + tangled issue comment <issue-id> --body "I'll fix this" 17 126 ``` 18 - cargo run -p tangled-cli -- --help 127 + 128 + ### Working with Pull Requests 129 + 130 + ```sh 131 + # Create a PR from a branch 132 + tangled pr create --repo myrepo --base main --head feature-branch --title "Add new feature" 133 + 134 + # Review a PR 135 + tangled pr review <pr-id> --approve --comment "LGTM!" 136 + 137 + # Merge a PR 138 + tangled pr merge <pr-id> 19 139 ``` 20 140 21 - Building requires network to fetch crates. 141 + ### CI/CD with Spindle 22 142 23 - ## Next steps 143 + ```sh 144 + # Enable spindle for your repo 145 + tangled spindle config --repo myrepo --enable 146 + 147 + # List pipeline runs 148 + tangled spindle list --repo myrepo 149 + 150 + # Stream logs from a workflow 151 + tangled spindle logs knot:rkey:workflow-name --follow 152 + 153 + # Manage secrets 154 + tangled spindle secret add --repo myrepo --key API_KEY --value "secret-value" 155 + tangled spindle secret list --repo myrepo 156 + ``` 157 + 158 + ## Development 159 + 160 + Run tests: 161 + ```sh 162 + cargo test 163 + ``` 164 + 165 + Run with debug output: 166 + ```sh 167 + cargo run -p tangled-cli -- --verbose <command> 168 + ``` 169 + 170 + Format code: 171 + ```sh 172 + cargo fmt 173 + ``` 174 + 175 + Check for issues: 176 + ```sh 177 + cargo clippy 178 + ``` 179 + 180 + ## Contributing 24 181 25 - - Implement `com.atproto.server.createSession` for auth 26 - - Wire repo list/create endpoints under `sh.tangled.*` 27 - - Persist sessions via keyring and load in CLI 28 - - Add output formatting (table/json) 182 + Contributions are welcome! Please feel free to submit issues or pull requests. 183 + 184 + ## License 29 185 186 + MIT OR Apache-2.0
+1239 -4
crates/tangled-api/src/client.rs
··· 23 23 } 24 24 25 25 fn xrpc_url(&self, method: &str) -> String { 26 - format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 26 + let base = self.base_url.trim_end_matches('/'); 27 + // Add https:// if no protocol is present 28 + let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") { 29 + base.to_string() 30 + } else { 31 + format!("https://{}", base) 32 + }; 33 + format!("{}/xrpc/{}", base_with_protocol, method) 27 34 } 28 35 29 36 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( ··· 49 56 Ok(res.json::<TRes>().await?) 50 57 } 51 58 52 - async fn get_json<TRes: DeserializeOwned>( 59 + async fn post<TReq: Serialize>( 60 + &self, 61 + method: &str, 62 + req: &TReq, 63 + bearer: Option<&str>, 64 + ) -> Result<()> { 65 + let url = self.xrpc_url(method); 66 + let client = reqwest::Client::new(); 67 + let mut reqb = client 68 + .post(url) 69 + .header(reqwest::header::CONTENT_TYPE, "application/json"); 70 + if let Some(token) = bearer { 71 + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 72 + } 73 + let res = reqb.json(req).send().await?; 74 + let status = res.status(); 75 + if !status.is_success() { 76 + let body = res.text().await.unwrap_or_default(); 77 + return Err(anyhow!("{}: {}", status, body)); 78 + } 79 + Ok(()) 80 + } 81 + 82 + pub async fn get_json<TRes: DeserializeOwned>( 53 83 &self, 54 84 method: &str, 55 85 params: &[(&str, String)], ··· 119 149 }) 120 150 } 121 151 152 + pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> { 153 + #[derive(Deserialize)] 154 + struct Res { 155 + #[serde(rename = "accessJwt")] 156 + access_jwt: String, 157 + #[serde(rename = "refreshJwt")] 158 + refresh_jwt: String, 159 + did: String, 160 + handle: String, 161 + } 162 + let url = self.xrpc_url("com.atproto.server.refreshSession"); 163 + let client = reqwest::Client::new(); 164 + let res = client 165 + .post(url) 166 + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt)) 167 + .send() 168 + .await?; 169 + let status = res.status(); 170 + if !status.is_success() { 171 + let body = res.text().await.unwrap_or_default(); 172 + return Err(anyhow!("{}: {}", status, body)); 173 + } 174 + let res_data: Res = res.json().await?; 175 + Ok(Session { 176 + access_jwt: res_data.access_jwt, 177 + refresh_jwt: res_data.refresh_jwt, 178 + did: res_data.did, 179 + handle: res_data.handle, 180 + ..Default::default() 181 + }) 182 + } 183 + 122 184 pub async fn list_repos( 123 185 &self, 124 186 user: Option<&str>, ··· 152 214 153 215 #[derive(Deserialize)] 154 216 struct RecordItem { 217 + uri: String, 155 218 value: Repository, 156 219 } 157 220 #[derive(Deserialize)] ··· 169 232 let res: ListRes = self 170 233 .get_json("com.atproto.repo.listRecords", &params, bearer) 171 234 .await?; 172 - let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect(); 235 + let mut repos: Vec<Repository> = res 236 + .records 237 + .into_iter() 238 + .map(|r| { 239 + let mut val = r.value; 240 + if val.rkey.is_none() { 241 + if let Some(k) = Self::uri_rkey(&r.uri) { 242 + val.rkey = Some(k); 243 + } 244 + } 245 + if val.did.is_none() { 246 + if let Some(d) = Self::uri_did(&r.uri) { 247 + val.did = Some(d); 248 + } 249 + } 250 + val 251 + }) 252 + .collect(); 173 253 // Apply optional filters client-side 174 254 if let Some(k) = knot { 175 255 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); ··· 246 326 struct GetSARes { 247 327 token: String, 248 328 } 329 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 249 330 let params = [ 250 331 ("aud", audience), 251 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 332 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 252 333 ]; 253 334 let sa: GetSARes = pds_client 254 335 .get_json( ··· 277 358 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 278 359 Ok(()) 279 360 } 361 + 362 + pub async fn get_repo_info( 363 + &self, 364 + owner: &str, 365 + name: &str, 366 + bearer: Option<&str>, 367 + ) -> Result<RepoRecord> { 368 + let did = if owner.starts_with("did:") { 369 + owner.to_string() 370 + } else { 371 + #[derive(Deserialize)] 372 + struct Res { 373 + did: String, 374 + } 375 + let params = [("handle", owner.to_string())]; 376 + let res: Res = self 377 + .get_json("com.atproto.identity.resolveHandle", &params, bearer) 378 + .await?; 379 + res.did 380 + }; 381 + 382 + #[derive(Deserialize)] 383 + struct RecordItem { 384 + uri: String, 385 + value: Repository, 386 + } 387 + #[derive(Deserialize)] 388 + struct ListRes { 389 + #[serde(default)] 390 + records: Vec<RecordItem>, 391 + } 392 + let params = vec![ 393 + ("repo", did.clone()), 394 + ("collection", "sh.tangled.repo".to_string()), 395 + ("limit", "100".to_string()), 396 + ]; 397 + let res: ListRes = self 398 + .get_json("com.atproto.repo.listRecords", &params, bearer) 399 + .await?; 400 + for item in res.records { 401 + if item.value.name == name { 402 + let rkey = 403 + Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?; 404 + let knot = item.value.knot.unwrap_or_default(); 405 + return Ok(RepoRecord { 406 + did: did.clone(), 407 + name: name.to_string(), 408 + rkey, 409 + knot, 410 + description: item.value.description, 411 + spindle: item.value.spindle, 412 + }); 413 + } 414 + } 415 + Err(anyhow!("repo not found for owner/name")) 416 + } 417 + 418 + pub async fn delete_repo( 419 + &self, 420 + did: &str, 421 + name: &str, 422 + pds_base: &str, 423 + access_jwt: &str, 424 + ) -> Result<()> { 425 + let pds_client = TangledClient::new(pds_base); 426 + let info = pds_client 427 + .get_repo_info(did, name, Some(access_jwt)) 428 + .await?; 429 + 430 + #[derive(Serialize)] 431 + struct DeleteRecordReq<'a> { 432 + repo: &'a str, 433 + collection: &'a str, 434 + rkey: &'a str, 435 + } 436 + let del = DeleteRecordReq { 437 + repo: did, 438 + collection: "sh.tangled.repo", 439 + rkey: &info.rkey, 440 + }; 441 + let _: serde_json::Value = pds_client 442 + .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 443 + .await?; 444 + 445 + let host = self 446 + .base_url 447 + .trim_end_matches('/') 448 + .strip_prefix("https://") 449 + .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 450 + .ok_or_else(|| anyhow!("invalid base_url"))?; 451 + let audience = format!("did:web:{}", host); 452 + #[derive(Deserialize)] 453 + struct GetSARes { 454 + token: String, 455 + } 456 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 457 + let params = [ 458 + ("aud", audience), 459 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 460 + ]; 461 + let sa: GetSARes = pds_client 462 + .get_json( 463 + "com.atproto.server.getServiceAuth", 464 + &params, 465 + Some(access_jwt), 466 + ) 467 + .await?; 468 + 469 + #[derive(Serialize)] 470 + struct DeleteReq<'a> { 471 + did: &'a str, 472 + name: &'a str, 473 + rkey: &'a str, 474 + } 475 + let body = DeleteReq { 476 + did, 477 + name, 478 + rkey: &info.rkey, 479 + }; 480 + let _: serde_json::Value = self 481 + .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) 482 + .await?; 483 + Ok(()) 484 + } 485 + 486 + pub async fn update_repo_knot( 487 + &self, 488 + did: &str, 489 + rkey: &str, 490 + new_knot: &str, 491 + pds_base: &str, 492 + access_jwt: &str, 493 + ) -> Result<()> { 494 + let pds_client = TangledClient::new(pds_base); 495 + #[derive(Deserialize, Serialize, Clone)] 496 + struct Rec { 497 + name: String, 498 + knot: String, 499 + #[serde(skip_serializing_if = "Option::is_none")] 500 + description: Option<String>, 501 + #[serde(rename = "createdAt")] 502 + created_at: String, 503 + } 504 + #[derive(Deserialize)] 505 + struct GetRes { 506 + value: Rec, 507 + } 508 + let params = [ 509 + ("repo", did.to_string()), 510 + ("collection", "sh.tangled.repo".to_string()), 511 + ("rkey", rkey.to_string()), 512 + ]; 513 + let got: GetRes = pds_client 514 + .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 515 + .await?; 516 + let mut rec = got.value; 517 + rec.knot = new_knot.to_string(); 518 + #[derive(Serialize)] 519 + struct PutReq<'a> { 520 + repo: &'a str, 521 + collection: &'a str, 522 + rkey: &'a str, 523 + validate: bool, 524 + record: Rec, 525 + } 526 + let req = PutReq { 527 + repo: did, 528 + collection: "sh.tangled.repo", 529 + rkey, 530 + validate: true, 531 + record: rec, 532 + }; 533 + let _: serde_json::Value = pds_client 534 + .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 535 + .await?; 536 + Ok(()) 537 + } 538 + 539 + pub async fn get_default_branch( 540 + &self, 541 + knot_host: &str, 542 + did: &str, 543 + name: &str, 544 + ) -> Result<DefaultBranch> { 545 + #[derive(Deserialize)] 546 + struct Res { 547 + name: String, 548 + hash: String, 549 + #[serde(rename = "shortHash")] 550 + short_hash: Option<String>, 551 + when: String, 552 + message: Option<String>, 553 + } 554 + let knot_client = TangledClient::new(knot_host); 555 + let repo_param = format!("{}/{}", did, name); 556 + let params = [("repo", repo_param)]; 557 + let res: Res = knot_client 558 + .get_json("sh.tangled.repo.getDefaultBranch", &params, None) 559 + .await?; 560 + Ok(DefaultBranch { 561 + name: res.name, 562 + hash: res.hash, 563 + short_hash: res.short_hash, 564 + when: res.when, 565 + message: res.message, 566 + }) 567 + } 568 + 569 + pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { 570 + let knot_client = TangledClient::new(knot_host); 571 + let repo_param = format!("{}/{}", did, name); 572 + let params = [("repo", repo_param)]; 573 + let res: serde_json::Value = knot_client 574 + .get_json("sh.tangled.repo.languages", &params, None) 575 + .await?; 576 + let langs = res 577 + .get("languages") 578 + .cloned() 579 + .unwrap_or(serde_json::json!([])); 580 + let languages: Vec<Language> = serde_json::from_value(langs)?; 581 + let total_size = res.get("totalSize").and_then(|v| v.as_u64()); 582 + let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); 583 + Ok(Languages { 584 + languages, 585 + total_size, 586 + total_files, 587 + }) 588 + } 589 + 590 + pub async fn star_repo( 591 + &self, 592 + pds_base: &str, 593 + access_jwt: &str, 594 + subject_at_uri: &str, 595 + user_did: &str, 596 + ) -> Result<String> { 597 + #[derive(Serialize)] 598 + struct Rec<'a> { 599 + subject: &'a str, 600 + #[serde(rename = "createdAt")] 601 + created_at: String, 602 + } 603 + #[derive(Serialize)] 604 + struct Req<'a> { 605 + repo: &'a str, 606 + collection: &'a str, 607 + validate: bool, 608 + record: Rec<'a>, 609 + } 610 + #[derive(Deserialize)] 611 + struct Res { 612 + uri: String, 613 + } 614 + let now = chrono::Utc::now().to_rfc3339(); 615 + let rec = Rec { 616 + subject: subject_at_uri, 617 + created_at: now, 618 + }; 619 + let req = Req { 620 + repo: user_did, 621 + collection: "sh.tangled.feed.star", 622 + validate: true, 623 + record: rec, 624 + }; 625 + let pds_client = TangledClient::new(pds_base); 626 + let res: Res = pds_client 627 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 628 + .await?; 629 + let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; 630 + Ok(rkey) 631 + } 632 + 633 + pub async fn unstar_repo( 634 + &self, 635 + pds_base: &str, 636 + access_jwt: &str, 637 + subject_at_uri: &str, 638 + user_did: &str, 639 + ) -> Result<()> { 640 + #[derive(Deserialize)] 641 + struct Item { 642 + uri: String, 643 + value: StarRecord, 644 + } 645 + #[derive(Deserialize)] 646 + struct ListRes { 647 + #[serde(default)] 648 + records: Vec<Item>, 649 + } 650 + let pds_client = TangledClient::new(pds_base); 651 + let params = vec![ 652 + ("repo", user_did.to_string()), 653 + ("collection", "sh.tangled.feed.star".to_string()), 654 + ("limit", "100".to_string()), 655 + ]; 656 + let res: ListRes = pds_client 657 + .get_json("com.atproto.repo.listRecords", &params, Some(access_jwt)) 658 + .await?; 659 + let mut rkey = None; 660 + for item in res.records { 661 + if item.value.subject == subject_at_uri { 662 + rkey = Self::uri_rkey(&item.uri); 663 + if rkey.is_some() { 664 + break; 665 + } 666 + } 667 + } 668 + let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; 669 + #[derive(Serialize)] 670 + struct Del<'a> { 671 + repo: &'a str, 672 + collection: &'a str, 673 + rkey: &'a str, 674 + } 675 + let del = Del { 676 + repo: user_did, 677 + collection: "sh.tangled.feed.star", 678 + rkey: &rkey, 679 + }; 680 + let _: serde_json::Value = pds_client 681 + .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 682 + .await?; 683 + Ok(()) 684 + } 685 + 686 + fn uri_rkey(uri: &str) -> Option<String> { 687 + uri.rsplit('/').next().map(|s| s.to_string()) 688 + } 689 + fn uri_did(uri: &str) -> Option<String> { 690 + let parts: Vec<&str> = uri.split('/').collect(); 691 + if parts.len() >= 3 { 692 + Some(parts[2].to_string()) 693 + } else { 694 + None 695 + } 696 + } 697 + 698 + // ========== Issues ========== 699 + pub async fn list_issues( 700 + &self, 701 + author_did: &str, 702 + repo_at_uri: Option<&str>, 703 + bearer: Option<&str>, 704 + ) -> Result<Vec<IssueRecord>> { 705 + #[derive(Deserialize)] 706 + struct Item { 707 + uri: String, 708 + value: Issue, 709 + } 710 + #[derive(Deserialize)] 711 + struct ListRes { 712 + #[serde(default)] 713 + records: Vec<Item>, 714 + } 715 + let params = vec![ 716 + ("repo", author_did.to_string()), 717 + ("collection", "sh.tangled.repo.issue".to_string()), 718 + ("limit", "100".to_string()), 719 + ]; 720 + let res: ListRes = self 721 + .get_json("com.atproto.repo.listRecords", &params, bearer) 722 + .await?; 723 + let mut out = vec![]; 724 + for it in res.records { 725 + if let Some(filter_repo) = repo_at_uri { 726 + if it.value.repo.as_str() != filter_repo { 727 + continue; 728 + } 729 + } 730 + let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 731 + out.push(IssueRecord { 732 + author_did: author_did.to_string(), 733 + rkey, 734 + issue: it.value, 735 + }); 736 + } 737 + Ok(out) 738 + } 739 + 740 + #[allow(clippy::too_many_arguments)] 741 + pub async fn create_issue( 742 + &self, 743 + author_did: &str, 744 + repo_did: &str, 745 + repo_rkey: &str, 746 + title: &str, 747 + body: Option<&str>, 748 + pds_base: &str, 749 + access_jwt: &str, 750 + ) -> Result<String> { 751 + #[derive(Serialize)] 752 + struct Rec<'a> { 753 + repo: &'a str, 754 + title: &'a str, 755 + #[serde(skip_serializing_if = "Option::is_none")] 756 + body: Option<&'a str>, 757 + #[serde(rename = "createdAt")] 758 + created_at: String, 759 + } 760 + #[derive(Serialize)] 761 + struct Req<'a> { 762 + repo: &'a str, 763 + collection: &'a str, 764 + validate: bool, 765 + record: Rec<'a>, 766 + } 767 + #[derive(Deserialize)] 768 + struct Res { 769 + uri: String, 770 + } 771 + let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 772 + let now = chrono::Utc::now().to_rfc3339(); 773 + let rec = Rec { 774 + repo: &issue_repo_at, 775 + title, 776 + body, 777 + created_at: now, 778 + }; 779 + let req = Req { 780 + repo: author_did, 781 + collection: "sh.tangled.repo.issue", 782 + validate: true, 783 + record: rec, 784 + }; 785 + let pds_client = TangledClient::new(pds_base); 786 + let res: Res = pds_client 787 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 788 + .await?; 789 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri")) 790 + } 791 + 792 + pub async fn comment_issue( 793 + &self, 794 + author_did: &str, 795 + issue_at: &str, 796 + body: &str, 797 + pds_base: &str, 798 + access_jwt: &str, 799 + ) -> Result<String> { 800 + #[derive(Serialize)] 801 + struct Rec<'a> { 802 + issue: &'a str, 803 + body: &'a str, 804 + #[serde(rename = "createdAt")] 805 + created_at: String, 806 + } 807 + #[derive(Serialize)] 808 + struct Req<'a> { 809 + repo: &'a str, 810 + collection: &'a str, 811 + validate: bool, 812 + record: Rec<'a>, 813 + } 814 + #[derive(Deserialize)] 815 + struct Res { 816 + uri: String, 817 + } 818 + let now = chrono::Utc::now().to_rfc3339(); 819 + let rec = Rec { 820 + issue: issue_at, 821 + body, 822 + created_at: now, 823 + }; 824 + let req = Req { 825 + repo: author_did, 826 + collection: "sh.tangled.repo.issue.comment", 827 + validate: true, 828 + record: rec, 829 + }; 830 + let pds_client = TangledClient::new(pds_base); 831 + let res: Res = pds_client 832 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 833 + .await?; 834 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri")) 835 + } 836 + 837 + pub async fn get_issue_record( 838 + &self, 839 + author_did: &str, 840 + rkey: &str, 841 + bearer: Option<&str>, 842 + ) -> Result<Issue> { 843 + #[derive(Deserialize)] 844 + struct GetRes { 845 + value: Issue, 846 + } 847 + let params = [ 848 + ("repo", author_did.to_string()), 849 + ("collection", "sh.tangled.repo.issue".to_string()), 850 + ("rkey", rkey.to_string()), 851 + ]; 852 + let res: GetRes = self 853 + .get_json("com.atproto.repo.getRecord", &params, bearer) 854 + .await?; 855 + Ok(res.value) 856 + } 857 + 858 + pub async fn put_issue_record( 859 + &self, 860 + author_did: &str, 861 + rkey: &str, 862 + record: &Issue, 863 + bearer: Option<&str>, 864 + ) -> Result<()> { 865 + #[derive(Serialize)] 866 + struct PutReq<'a> { 867 + repo: &'a str, 868 + collection: &'a str, 869 + rkey: &'a str, 870 + validate: bool, 871 + record: &'a Issue, 872 + } 873 + let req = PutReq { 874 + repo: author_did, 875 + collection: "sh.tangled.repo.issue", 876 + rkey, 877 + validate: true, 878 + record, 879 + }; 880 + let _: serde_json::Value = self 881 + .post_json("com.atproto.repo.putRecord", &req, bearer) 882 + .await?; 883 + Ok(()) 884 + } 885 + 886 + pub async fn set_issue_state( 887 + &self, 888 + author_did: &str, 889 + issue_at: &str, 890 + state_nsid: &str, 891 + pds_base: &str, 892 + access_jwt: &str, 893 + ) -> Result<String> { 894 + #[derive(Serialize)] 895 + struct Rec<'a> { 896 + issue: &'a str, 897 + state: &'a str, 898 + } 899 + #[derive(Serialize)] 900 + struct Req<'a> { 901 + repo: &'a str, 902 + collection: &'a str, 903 + validate: bool, 904 + record: Rec<'a>, 905 + } 906 + #[derive(Deserialize)] 907 + struct Res { 908 + uri: String, 909 + } 910 + let rec = Rec { 911 + issue: issue_at, 912 + state: state_nsid, 913 + }; 914 + let req = Req { 915 + repo: author_did, 916 + collection: "sh.tangled.repo.issue.state", 917 + validate: true, 918 + record: rec, 919 + }; 920 + let pds_client = TangledClient::new(pds_base); 921 + let res: Res = pds_client 922 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 923 + .await?; 924 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) 925 + } 926 + 927 + pub async fn get_pull_record( 928 + &self, 929 + author_did: &str, 930 + rkey: &str, 931 + bearer: Option<&str>, 932 + ) -> Result<Pull> { 933 + #[derive(Deserialize)] 934 + struct GetRes { 935 + value: Pull, 936 + } 937 + let params = [ 938 + ("repo", author_did.to_string()), 939 + ("collection", "sh.tangled.repo.pull".to_string()), 940 + ("rkey", rkey.to_string()), 941 + ]; 942 + let res: GetRes = self 943 + .get_json("com.atproto.repo.getRecord", &params, bearer) 944 + .await?; 945 + Ok(res.value) 946 + } 947 + 948 + // ========== Pull Requests ========== 949 + pub async fn list_pulls( 950 + &self, 951 + author_did: &str, 952 + target_repo_at_uri: Option<&str>, 953 + bearer: Option<&str>, 954 + ) -> Result<Vec<PullRecord>> { 955 + #[derive(Deserialize)] 956 + struct Item { 957 + uri: String, 958 + value: Pull, 959 + } 960 + #[derive(Deserialize)] 961 + struct ListRes { 962 + #[serde(default)] 963 + records: Vec<Item>, 964 + } 965 + let params = vec![ 966 + ("repo", author_did.to_string()), 967 + ("collection", "sh.tangled.repo.pull".to_string()), 968 + ("limit", "100".to_string()), 969 + ]; 970 + let res: ListRes = self 971 + .get_json("com.atproto.repo.listRecords", &params, bearer) 972 + .await?; 973 + let mut out = vec![]; 974 + for it in res.records { 975 + if let Some(target) = target_repo_at_uri { 976 + if it.value.target.repo.as_str() != target { 977 + continue; 978 + } 979 + } 980 + let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 981 + out.push(PullRecord { 982 + author_did: author_did.to_string(), 983 + rkey, 984 + pull: it.value, 985 + }); 986 + } 987 + Ok(out) 988 + } 989 + 990 + #[allow(clippy::too_many_arguments)] 991 + pub async fn create_pull( 992 + &self, 993 + author_did: &str, 994 + repo_did: &str, 995 + repo_rkey: &str, 996 + target_branch: &str, 997 + patch: &str, 998 + title: &str, 999 + body: Option<&str>, 1000 + pds_base: &str, 1001 + access_jwt: &str, 1002 + ) -> Result<String> { 1003 + #[derive(Serialize)] 1004 + struct Target<'a> { 1005 + repo: &'a str, 1006 + branch: &'a str, 1007 + } 1008 + #[derive(Serialize)] 1009 + struct Rec<'a> { 1010 + target: Target<'a>, 1011 + title: &'a str, 1012 + #[serde(skip_serializing_if = "Option::is_none")] 1013 + body: Option<&'a str>, 1014 + patch: &'a str, 1015 + #[serde(rename = "createdAt")] 1016 + created_at: String, 1017 + } 1018 + #[derive(Serialize)] 1019 + struct Req<'a> { 1020 + repo: &'a str, 1021 + collection: &'a str, 1022 + validate: bool, 1023 + record: Rec<'a>, 1024 + } 1025 + #[derive(Deserialize)] 1026 + struct Res { 1027 + uri: String, 1028 + } 1029 + let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey); 1030 + let now = chrono::Utc::now().to_rfc3339(); 1031 + let rec = Rec { 1032 + target: Target { 1033 + repo: &repo_at, 1034 + branch: target_branch, 1035 + }, 1036 + title, 1037 + body, 1038 + patch, 1039 + created_at: now, 1040 + }; 1041 + let req = Req { 1042 + repo: author_did, 1043 + collection: "sh.tangled.repo.pull", 1044 + validate: true, 1045 + record: rec, 1046 + }; 1047 + let pds_client = TangledClient::new(pds_base); 1048 + let res: Res = pds_client 1049 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1050 + .await?; 1051 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri")) 1052 + } 1053 + 1054 + // ========== Spindle: Secrets Management ========== 1055 + pub async fn list_repo_secrets( 1056 + &self, 1057 + pds_base: &str, 1058 + access_jwt: &str, 1059 + repo_at: &str, 1060 + ) -> Result<Vec<Secret>> { 1061 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1062 + #[derive(Deserialize)] 1063 + struct Res { 1064 + secrets: Vec<Secret>, 1065 + } 1066 + let params = [("repo", repo_at.to_string())]; 1067 + let res: Res = self 1068 + .get_json("sh.tangled.repo.listSecrets", &params, Some(&sa)) 1069 + .await?; 1070 + Ok(res.secrets) 1071 + } 1072 + 1073 + pub async fn add_repo_secret( 1074 + &self, 1075 + pds_base: &str, 1076 + access_jwt: &str, 1077 + repo_at: &str, 1078 + key: &str, 1079 + value: &str, 1080 + ) -> Result<()> { 1081 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1082 + #[derive(Serialize)] 1083 + struct Req<'a> { 1084 + repo: &'a str, 1085 + key: &'a str, 1086 + value: &'a str, 1087 + } 1088 + let body = Req { 1089 + repo: repo_at, 1090 + key, 1091 + value, 1092 + }; 1093 + self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1094 + .await 1095 + } 1096 + 1097 + pub async fn remove_repo_secret( 1098 + &self, 1099 + pds_base: &str, 1100 + access_jwt: &str, 1101 + repo_at: &str, 1102 + key: &str, 1103 + ) -> Result<()> { 1104 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1105 + #[derive(Serialize)] 1106 + struct Req<'a> { 1107 + repo: &'a str, 1108 + key: &'a str, 1109 + } 1110 + let body = Req { repo: repo_at, key }; 1111 + self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1112 + .await 1113 + } 1114 + 1115 + async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1116 + let base_trimmed = self.base_url.trim_end_matches('/'); 1117 + let host = base_trimmed 1118 + .strip_prefix("https://") 1119 + .or_else(|| base_trimmed.strip_prefix("http://")) 1120 + .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1121 + let audience = format!("did:web:{}", host); 1122 + #[derive(Deserialize)] 1123 + struct GetSARes { 1124 + token: String, 1125 + } 1126 + let pds = TangledClient::new(pds_base); 1127 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1128 + let params = [ 1129 + ("aud", audience), 1130 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1131 + ]; 1132 + let sa: GetSARes = pds 1133 + .get_json( 1134 + "com.atproto.server.getServiceAuth", 1135 + &params, 1136 + Some(access_jwt), 1137 + ) 1138 + .await?; 1139 + Ok(sa.token) 1140 + } 1141 + 1142 + pub async fn comment_pull( 1143 + &self, 1144 + author_did: &str, 1145 + pull_at: &str, 1146 + body: &str, 1147 + pds_base: &str, 1148 + access_jwt: &str, 1149 + ) -> Result<String> { 1150 + #[derive(Serialize)] 1151 + struct Rec<'a> { 1152 + pull: &'a str, 1153 + body: &'a str, 1154 + #[serde(rename = "createdAt")] 1155 + created_at: String, 1156 + } 1157 + #[derive(Serialize)] 1158 + struct Req<'a> { 1159 + repo: &'a str, 1160 + collection: &'a str, 1161 + validate: bool, 1162 + record: Rec<'a>, 1163 + } 1164 + #[derive(Deserialize)] 1165 + struct Res { 1166 + uri: String, 1167 + } 1168 + let now = chrono::Utc::now().to_rfc3339(); 1169 + let rec = Rec { 1170 + pull: pull_at, 1171 + body, 1172 + created_at: now, 1173 + }; 1174 + let req = Req { 1175 + repo: author_did, 1176 + collection: "sh.tangled.repo.pull.comment", 1177 + validate: true, 1178 + record: rec, 1179 + }; 1180 + let pds_client = TangledClient::new(pds_base); 1181 + let res: Res = pds_client 1182 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 1183 + .await?; 1184 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1185 + } 1186 + 1187 + pub async fn merge_pull( 1188 + &self, 1189 + pull_did: &str, 1190 + pull_rkey: &str, 1191 + repo_did: &str, 1192 + repo_name: &str, 1193 + pds_base: &str, 1194 + access_jwt: &str, 1195 + ) -> Result<()> { 1196 + // Fetch the pull request to get patch and target branch 1197 + let pds_client = TangledClient::new(pds_base); 1198 + let pull = pds_client 1199 + .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1200 + .await?; 1201 + 1202 + // Get service auth token for the knot 1203 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1204 + 1205 + #[derive(Serialize)] 1206 + struct MergeReq<'a> { 1207 + did: &'a str, 1208 + name: &'a str, 1209 + patch: &'a str, 1210 + branch: &'a str, 1211 + #[serde(skip_serializing_if = "Option::is_none")] 1212 + #[serde(rename = "commitMessage")] 1213 + commit_message: Option<&'a str>, 1214 + #[serde(skip_serializing_if = "Option::is_none")] 1215 + #[serde(rename = "commitBody")] 1216 + commit_body: Option<&'a str>, 1217 + } 1218 + 1219 + let commit_body = if pull.body.is_empty() { 1220 + None 1221 + } else { 1222 + Some(pull.body.as_str()) 1223 + }; 1224 + 1225 + let req = MergeReq { 1226 + did: repo_did, 1227 + name: repo_name, 1228 + patch: &pull.patch, 1229 + branch: &pull.target.branch, 1230 + commit_message: Some(&pull.title), 1231 + commit_body, 1232 + }; 1233 + 1234 + let _: serde_json::Value = self 1235 + .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1236 + .await?; 1237 + Ok(()) 1238 + } 1239 + 1240 + pub async fn merge_check( 1241 + &self, 1242 + repo_did: &str, 1243 + repo_name: &str, 1244 + branch: &str, 1245 + patch: &str, 1246 + pds_base: &str, 1247 + access_jwt: &str, 1248 + ) -> Result<MergeCheckResponse> { 1249 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1250 + 1251 + let req = MergeCheckRequest { 1252 + did: repo_did.to_string(), 1253 + name: repo_name.to_string(), 1254 + branch: branch.to_string(), 1255 + patch: patch.to_string(), 1256 + }; 1257 + 1258 + self.post_json("sh.tangled.repo.mergeCheck", &req, Some(&sa)) 1259 + .await 1260 + } 1261 + 1262 + pub async fn update_repo_spindle( 1263 + &self, 1264 + did: &str, 1265 + rkey: &str, 1266 + new_spindle: Option<&str>, 1267 + pds_base: &str, 1268 + access_jwt: &str, 1269 + ) -> Result<()> { 1270 + let pds_client = TangledClient::new(pds_base); 1271 + #[derive(Deserialize, Serialize, Clone)] 1272 + struct Rec { 1273 + name: String, 1274 + knot: String, 1275 + #[serde(skip_serializing_if = "Option::is_none")] 1276 + description: Option<String>, 1277 + #[serde(skip_serializing_if = "Option::is_none")] 1278 + spindle: Option<String>, 1279 + #[serde(rename = "createdAt")] 1280 + created_at: String, 1281 + } 1282 + #[derive(Deserialize)] 1283 + struct GetRes { 1284 + value: Rec, 1285 + } 1286 + let params = [ 1287 + ("repo", did.to_string()), 1288 + ("collection", "sh.tangled.repo".to_string()), 1289 + ("rkey", rkey.to_string()), 1290 + ]; 1291 + let got: GetRes = pds_client 1292 + .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1293 + .await?; 1294 + let mut rec = got.value; 1295 + rec.spindle = new_spindle.map(|s| s.to_string()); 1296 + #[derive(Serialize)] 1297 + struct PutReq<'a> { 1298 + repo: &'a str, 1299 + collection: &'a str, 1300 + rkey: &'a str, 1301 + validate: bool, 1302 + record: Rec, 1303 + } 1304 + let req = PutReq { 1305 + repo: did, 1306 + collection: "sh.tangled.repo", 1307 + rkey, 1308 + validate: true, 1309 + record: rec, 1310 + }; 1311 + let _: serde_json::Value = pds_client 1312 + .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1313 + .await?; 1314 + Ok(()) 1315 + } 1316 + 1317 + pub async fn list_pipelines( 1318 + &self, 1319 + repo_did: &str, 1320 + bearer: Option<&str>, 1321 + ) -> Result<Vec<PipelineRecord>> { 1322 + #[derive(Deserialize)] 1323 + struct Item { 1324 + uri: String, 1325 + value: Pipeline, 1326 + } 1327 + #[derive(Deserialize)] 1328 + struct ListRes { 1329 + #[serde(default)] 1330 + records: Vec<Item>, 1331 + } 1332 + let params = vec![ 1333 + ("repo", repo_did.to_string()), 1334 + ("collection", "sh.tangled.pipeline".to_string()), 1335 + ("limit", "100".to_string()), 1336 + ]; 1337 + let res: ListRes = self 1338 + .get_json("com.atproto.repo.listRecords", &params, bearer) 1339 + .await?; 1340 + let mut out = vec![]; 1341 + for it in res.records { 1342 + let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1343 + out.push(PipelineRecord { 1344 + rkey, 1345 + pipeline: it.value, 1346 + }); 1347 + } 1348 + Ok(out) 1349 + } 280 1350 } 281 1351 282 1352 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 286 1356 pub name: String, 287 1357 pub knot: Option<String>, 288 1358 pub description: Option<String>, 1359 + pub spindle: Option<String>, 289 1360 #[serde(default)] 290 1361 pub private: bool, 291 1362 } 292 1363 1364 + // Issue record value 1365 + #[derive(Debug, Clone, Serialize, Deserialize)] 1366 + pub struct Issue { 1367 + pub repo: String, 1368 + pub title: String, 1369 + #[serde(default)] 1370 + pub body: String, 1371 + #[serde(rename = "createdAt")] 1372 + pub created_at: String, 1373 + } 1374 + 1375 + #[derive(Debug, Clone)] 1376 + pub struct IssueRecord { 1377 + pub author_did: String, 1378 + pub rkey: String, 1379 + pub issue: Issue, 1380 + } 1381 + 1382 + // Pull record value (subset) 1383 + #[derive(Debug, Clone, Serialize, Deserialize)] 1384 + pub struct PullTarget { 1385 + pub repo: String, 1386 + pub branch: String, 1387 + } 1388 + 1389 + #[derive(Debug, Clone, Serialize, Deserialize)] 1390 + pub struct Pull { 1391 + pub target: PullTarget, 1392 + pub title: String, 1393 + #[serde(default)] 1394 + pub body: String, 1395 + pub patch: String, 1396 + #[serde(rename = "createdAt")] 1397 + pub created_at: String, 1398 + // Stack support fields 1399 + #[serde(skip_serializing_if = "Option::is_none")] 1400 + pub stack_id: Option<String>, 1401 + #[serde(skip_serializing_if = "Option::is_none")] 1402 + pub change_id: Option<String>, 1403 + #[serde(skip_serializing_if = "Option::is_none")] 1404 + pub parent_change_id: Option<String>, 1405 + } 1406 + 1407 + #[derive(Debug, Clone)] 1408 + pub struct PullRecord { 1409 + pub author_did: String, 1410 + pub rkey: String, 1411 + pub pull: Pull, 1412 + } 1413 + 1414 + // Merge check types for stacked diff conflict detection 1415 + #[derive(Debug, Clone, Serialize, Deserialize)] 1416 + pub struct MergeCheckRequest { 1417 + pub did: String, 1418 + pub name: String, 1419 + pub branch: String, 1420 + pub patch: String, 1421 + } 1422 + 1423 + #[derive(Debug, Clone, Serialize, Deserialize)] 1424 + pub struct MergeCheckResponse { 1425 + pub is_conflicted: bool, 1426 + #[serde(default)] 1427 + pub conflicts: Vec<ConflictInfo>, 1428 + #[serde(skip_serializing_if = "Option::is_none")] 1429 + pub message: Option<String>, 1430 + #[serde(skip_serializing_if = "Option::is_none")] 1431 + pub error: Option<String>, 1432 + } 1433 + 1434 + #[derive(Debug, Clone, Serialize, Deserialize)] 1435 + pub struct ConflictInfo { 1436 + pub filename: String, 1437 + pub reason: String, 1438 + } 1439 + 1440 + #[derive(Debug, Clone)] 1441 + pub struct RepoRecord { 1442 + pub did: String, 1443 + pub name: String, 1444 + pub rkey: String, 1445 + pub knot: String, 1446 + pub description: Option<String>, 1447 + pub spindle: Option<String>, 1448 + } 1449 + 1450 + #[derive(Debug, Clone, Serialize, Deserialize)] 1451 + pub struct DefaultBranch { 1452 + pub name: String, 1453 + pub hash: String, 1454 + #[serde(skip_serializing_if = "Option::is_none")] 1455 + pub short_hash: Option<String>, 1456 + pub when: String, 1457 + #[serde(skip_serializing_if = "Option::is_none")] 1458 + pub message: Option<String>, 1459 + } 1460 + 1461 + #[derive(Debug, Clone, Serialize, Deserialize)] 1462 + pub struct Language { 1463 + pub name: String, 1464 + pub size: u64, 1465 + pub percentage: u64, 1466 + } 1467 + 1468 + #[derive(Debug, Clone, Serialize, Deserialize)] 1469 + pub struct Languages { 1470 + pub languages: Vec<Language>, 1471 + #[serde(skip_serializing_if = "Option::is_none")] 1472 + pub total_size: Option<u64>, 1473 + #[serde(skip_serializing_if = "Option::is_none")] 1474 + pub total_files: Option<u64>, 1475 + } 1476 + 1477 + #[derive(Debug, Clone, Serialize, Deserialize)] 1478 + pub struct StarRecord { 1479 + pub subject: String, 1480 + #[serde(rename = "createdAt")] 1481 + pub created_at: String, 1482 + } 1483 + 1484 + #[derive(Debug, Clone, Serialize, Deserialize)] 1485 + pub struct Secret { 1486 + pub repo: String, 1487 + pub key: String, 1488 + #[serde(rename = "createdAt")] 1489 + pub created_at: String, 1490 + #[serde(rename = "createdBy")] 1491 + pub created_by: String, 1492 + } 1493 + 293 1494 #[derive(Debug, Clone)] 294 1495 pub struct CreateRepoOptions<'a> { 295 1496 pub did: &'a str, ··· 301 1502 pub pds_base: &'a str, 302 1503 pub access_jwt: &'a str, 303 1504 } 1505 + 1506 + #[derive(Debug, Clone, Serialize, Deserialize)] 1507 + pub struct TriggerMetadata { 1508 + pub kind: String, 1509 + pub repo: TriggerRepo, 1510 + } 1511 + 1512 + #[derive(Debug, Clone, Serialize, Deserialize)] 1513 + pub struct TriggerRepo { 1514 + pub knot: String, 1515 + pub did: String, 1516 + pub repo: String, 1517 + #[serde(rename = "defaultBranch")] 1518 + pub default_branch: String, 1519 + } 1520 + 1521 + #[derive(Debug, Clone, Serialize, Deserialize)] 1522 + pub struct Workflow { 1523 + pub name: String, 1524 + pub engine: String, 1525 + } 1526 + 1527 + #[derive(Debug, Clone, Serialize, Deserialize)] 1528 + pub struct Pipeline { 1529 + #[serde(rename = "triggerMetadata")] 1530 + pub trigger_metadata: TriggerMetadata, 1531 + pub workflows: Vec<Workflow>, 1532 + } 1533 + 1534 + #[derive(Debug, Clone)] 1535 + pub struct PipelineRecord { 1536 + pub rkey: String, 1537 + pub pipeline: Pipeline, 1538 + }
+4
crates/tangled-api/src/lib.rs
··· 1 1 pub mod client; 2 2 3 3 pub use client::TangledClient; 4 + pub use client::{ 5 + ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, 6 + MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret, 7 + };
+5 -1
crates/tangled-cli/Cargo.toml
··· 14 14 serde = { workspace = true, features = ["derive"] } 15 15 serde_json = { workspace = true } 16 16 tokio = { workspace = true, features = ["full"] } 17 + git2 = { workspace = true } 18 + url = { workspace = true } 19 + tokio-tungstenite = { workspace = true } 20 + futures-util = { workspace = true } 21 + chrono = { workspace = true } 17 22 18 23 # Internal crates 19 24 tangled-config = { path = "../tangled-config" } 20 25 tangled-api = { path = "../tangled-api" } 21 26 tangled-git = { path = "../tangled-git" } 22 -
+57 -36
crates/tangled-cli/src/cli.rs
··· 284 284 #[derive(Args, Debug, Clone)] 285 285 pub struct PrMergeArgs { 286 286 pub id: String, 287 - #[arg(long, default_value_t = false)] 288 - pub squash: bool, 289 - #[arg(long, default_value_t = false)] 290 - pub rebase: bool, 291 - #[arg(long, default_value_t = false)] 292 - pub no_ff: bool, 293 287 } 294 288 295 289 #[derive(Subcommand, Debug, Clone)] 296 290 pub enum KnotCommand { 297 - List(KnotListArgs), 298 - Add(KnotAddArgs), 299 - Verify(KnotVerifyArgs), 300 - SetDefault(KnotRefArgs), 301 - Remove(KnotRefArgs), 302 - } 303 - 304 - #[derive(Args, Debug, Clone)] 305 - pub struct KnotListArgs { 306 - #[arg(long, default_value_t = false)] 307 - pub public: bool, 308 - #[arg(long, default_value_t = false)] 309 - pub owned: bool, 291 + /// Migrate a repository to another knot 292 + Migrate(KnotMigrateArgs), 310 293 } 311 294 312 295 #[derive(Args, Debug, Clone)] 313 - pub struct KnotAddArgs { 314 - pub url: String, 296 + pub struct KnotMigrateArgs { 297 + /// Repo to migrate: <owner>/<name> (owner defaults to your handle) 315 298 #[arg(long)] 316 - pub did: Option<String>, 317 - #[arg(long)] 318 - pub name: Option<String>, 319 - #[arg(long, default_value_t = false)] 320 - pub verify: bool, 321 - } 322 - 323 - #[derive(Args, Debug, Clone)] 324 - pub struct KnotVerifyArgs { 325 - pub url: String, 326 - } 327 - 328 - #[derive(Args, Debug, Clone)] 329 - pub struct KnotRefArgs { 330 - pub url: String, 299 + pub repo: String, 300 + /// Target knot hostname (e.g. knot1.tangled.sh) 301 + #[arg(long, value_name = "HOST")] 302 + pub to: String, 303 + /// Use HTTPS source when seeding new repo 304 + #[arg(long, default_value_t = true)] 305 + pub https: bool, 306 + /// Update PDS record knot field after seeding 307 + #[arg(long, default_value_t = true)] 308 + pub update_record: bool, 331 309 } 332 310 333 311 #[derive(Subcommand, Debug, Clone)] ··· 336 314 Config(SpindleConfigArgs), 337 315 Run(SpindleRunArgs), 338 316 Logs(SpindleLogsArgs), 317 + /// Secrets management 318 + #[command(subcommand)] 319 + Secret(SpindleSecretCommand), 339 320 } 340 321 341 322 #[derive(Args, Debug, Clone)] ··· 374 355 #[arg(long)] 375 356 pub lines: Option<usize>, 376 357 } 358 + 359 + #[derive(Subcommand, Debug, Clone)] 360 + pub enum SpindleSecretCommand { 361 + /// List secrets for a repo 362 + List(SpindleSecretListArgs), 363 + /// Add or update a secret 364 + Add(SpindleSecretAddArgs), 365 + /// Remove a secret 366 + Remove(SpindleSecretRemoveArgs), 367 + } 368 + 369 + #[derive(Args, Debug, Clone)] 370 + pub struct SpindleSecretListArgs { 371 + /// Repo: <owner>/<name> 372 + #[arg(long)] 373 + pub repo: String, 374 + } 375 + 376 + #[derive(Args, Debug, Clone)] 377 + pub struct SpindleSecretAddArgs { 378 + /// Repo: <owner>/<name> 379 + #[arg(long)] 380 + pub repo: String, 381 + /// Secret key 382 + #[arg(long)] 383 + pub key: String, 384 + /// Secret value (use '@filename' to read from file, '-' to read from stdin) 385 + #[arg(long)] 386 + pub value: String, 387 + } 388 + 389 + #[derive(Args, Debug, Clone)] 390 + pub struct SpindleSecretRemoveArgs { 391 + /// Repo: <owner>/<name> 392 + #[arg(long)] 393 + pub repo: String, 394 + /// Secret key 395 + #[arg(long)] 396 + pub key: String, 397 + }
+7 -1
crates/tangled-cli/src/commands/auth.rs
··· 26 26 .unwrap_or_else(|| "https://bsky.social".to_string()); 27 27 28 28 let client = tangled_api::TangledClient::new(&pds); 29 - let mut session = client.login_with_password(&handle, &password, &pds).await?; 29 + let mut session = match client.login_with_password(&handle, &password, &pds).await { 30 + Ok(sess) => sess, 31 + Err(e) => { 32 + println!("\x1b[93mIf you're on your own PDS, make sure to pass the --pds flag\x1b[0m"); 33 + return Err(e); 34 + } 35 + }; 30 36 session.pds = Some(pds.clone()); 31 37 SessionManager::default().save(&session)?; 32 38 println!("Logged in as '{}' ({})", session.handle, session.did);
+192 -21
crates/tangled-cli/src/commands/issue.rs
··· 2 2 Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs, 3 3 IssueShowArgs, 4 4 }; 5 - use anyhow::Result; 5 + use anyhow::{anyhow, Result}; 6 + use tangled_api::Issue; 6 7 7 8 pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { 8 9 match cmd { ··· 15 16 } 16 17 17 18 async fn list(args: IssueListArgs) -> Result<()> { 18 - println!( 19 - "Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}", 20 - args.repo, args.state, args.author, args.label, args.assigned 21 - ); 19 + let session = crate::util::load_session_with_refresh().await?; 20 + let pds = session 21 + .pds 22 + .clone() 23 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 24 + .unwrap_or_else(|| "https://bsky.social".into()); 25 + let client = tangled_api::TangledClient::new(&pds); 26 + 27 + let repo_filter_at = if let Some(repo) = &args.repo { 28 + let (owner, name) = parse_repo_ref(repo, &session.handle); 29 + let info = client 30 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 31 + .await?; 32 + Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) 33 + } else { 34 + None 35 + }; 36 + 37 + let items = client 38 + .list_issues( 39 + &session.did, 40 + repo_filter_at.as_deref(), 41 + Some(session.access_jwt.as_str()), 42 + ) 43 + .await?; 44 + if items.is_empty() { 45 + println!("No issues found (showing only issues you created)"); 46 + } else { 47 + println!("RKEY\tTITLE\tREPO"); 48 + for it in items { 49 + println!("{}\t{}\t{}", it.rkey, it.issue.title, it.issue.repo); 50 + } 51 + } 22 52 Ok(()) 23 53 } 24 54 25 55 async fn create(args: IssueCreateArgs) -> Result<()> { 26 - println!( 27 - "Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}", 28 - args.repo, args.title, args.body, args.label, args.assign 29 - ); 56 + let session = crate::util::load_session_with_refresh().await?; 57 + let pds = session 58 + .pds 59 + .clone() 60 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 61 + .unwrap_or_else(|| "https://bsky.social".into()); 62 + let client = tangled_api::TangledClient::new(&pds); 63 + 64 + let repo = args 65 + .repo 66 + .as_ref() 67 + .ok_or_else(|| anyhow!("--repo is required for issue create"))?; 68 + let (owner, name) = parse_repo_ref(repo, &session.handle); 69 + let info = client 70 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 71 + .await?; 72 + let title = args 73 + .title 74 + .as_deref() 75 + .ok_or_else(|| anyhow!("--title is required for issue create"))?; 76 + let rkey = client 77 + .create_issue( 78 + &session.did, 79 + &info.did, 80 + &info.rkey, 81 + title, 82 + args.body.as_deref(), 83 + &pds, 84 + &session.access_jwt, 85 + ) 86 + .await?; 87 + println!("Created issue rkey={} in {}/{}", rkey, owner, name); 30 88 Ok(()) 31 89 } 32 90 33 91 async fn show(args: IssueShowArgs) -> Result<()> { 34 - println!( 35 - "Issue show (stub) id={} comments={} json={}", 36 - args.id, args.comments, args.json 37 - ); 92 + // For now, show only accepts at-uri or did:rkey or rkey (for your DID) 93 + let session = crate::util::load_session_with_refresh().await?; 94 + let id = args.id; 95 + let (did, rkey) = parse_record_id(&id, &session.did)?; 96 + let pds = session 97 + .pds 98 + .clone() 99 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 100 + .unwrap_or_else(|| "https://bsky.social".into()); 101 + let client = tangled_api::TangledClient::new(&pds); 102 + // Fetch all issues by this DID and find rkey 103 + let items = client 104 + .list_issues(&did, None, Some(session.access_jwt.as_str())) 105 + .await?; 106 + if let Some(it) = items.into_iter().find(|i| i.rkey == rkey) { 107 + println!("TITLE: {}", it.issue.title); 108 + if !it.issue.body.is_empty() { 109 + println!("BODY:\n{}", it.issue.body); 110 + } 111 + println!("REPO: {}", it.issue.repo); 112 + println!("AUTHOR: {}", it.author_did); 113 + println!("RKEY: {}", rkey); 114 + } else { 115 + println!("Issue not found for did={} rkey={}", did, rkey); 116 + } 38 117 Ok(()) 39 118 } 40 119 41 120 async fn edit(args: IssueEditArgs) -> Result<()> { 42 - println!( 43 - "Issue edit (stub) id={} title={:?} body={:?} state={:?}", 44 - args.id, args.title, args.body, args.state 45 - ); 121 + // Simple edit: fetch existing record and putRecord with new title/body 122 + let session = crate::util::load_session_with_refresh().await?; 123 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 124 + let pds = session 125 + .pds 126 + .clone() 127 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 128 + .unwrap_or_else(|| "https://bsky.social".into()); 129 + // Get existing 130 + let client = tangled_api::TangledClient::new(&pds); 131 + let mut rec: Issue = client 132 + .get_issue_record(&did, &rkey, Some(session.access_jwt.as_str())) 133 + .await?; 134 + if let Some(t) = args.title.as_deref() { 135 + rec.title = t.to_string(); 136 + } 137 + if let Some(b) = args.body.as_deref() { 138 + rec.body = b.to_string(); 139 + } 140 + // Put record back 141 + client 142 + .put_issue_record(&did, &rkey, &rec, Some(session.access_jwt.as_str())) 143 + .await?; 144 + 145 + // Optional state change 146 + if let Some(state) = args.state.as_deref() { 147 + let state_nsid = match state { 148 + "open" => "sh.tangled.repo.issue.state.open", 149 + "closed" => "sh.tangled.repo.issue.state.closed", 150 + other => { 151 + return Err(anyhow!(format!( 152 + "unknown state '{}', expected 'open' or 'closed'", 153 + other 154 + ))) 155 + } 156 + }; 157 + let issue_at = rec.repo.clone(); 158 + client 159 + .set_issue_state( 160 + &session.did, 161 + &issue_at, 162 + state_nsid, 163 + &pds, 164 + &session.access_jwt, 165 + ) 166 + .await?; 167 + } 168 + println!("Updated issue {}:{}", did, rkey); 46 169 Ok(()) 47 170 } 48 171 49 172 async fn comment(args: IssueCommentArgs) -> Result<()> { 50 - println!( 51 - "Issue comment (stub) id={} close={} body={:?}", 52 - args.id, args.close, args.body 53 - ); 173 + let session = crate::util::load_session_with_refresh().await?; 174 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 175 + let pds = session 176 + .pds 177 + .clone() 178 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 179 + .unwrap_or_else(|| "https://bsky.social".into()); 180 + // Resolve issue AT-URI 181 + let client = tangled_api::TangledClient::new(&pds); 182 + let issue_at = client 183 + .get_issue_record(&did, &rkey, Some(session.access_jwt.as_str())) 184 + .await? 185 + .repo; 186 + if let Some(body) = args.body.as_deref() { 187 + client 188 + .comment_issue(&session.did, &issue_at, body, &pds, &session.access_jwt) 189 + .await?; 190 + println!("Comment posted"); 191 + } 192 + if args.close { 193 + client 194 + .set_issue_state( 195 + &session.did, 196 + &issue_at, 197 + "sh.tangled.repo.issue.state.closed", 198 + &pds, 199 + &session.access_jwt, 200 + ) 201 + .await?; 202 + println!("Issue closed"); 203 + } 54 204 Ok(()) 55 205 } 206 + 207 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 208 + if let Some((owner, name)) = spec.split_once('/') { 209 + (owner, name) 210 + } else { 211 + (default_owner, spec) 212 + } 213 + } 214 + 215 + fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { 216 + if let Some(rest) = id.strip_prefix("at://") { 217 + let parts: Vec<&str> = rest.split('/').collect(); 218 + if parts.len() >= 4 { 219 + return Ok((parts[0].to_string(), parts[3].to_string())); 220 + } 221 + } 222 + if let Some((did, rkey)) = id.split_once(':') { 223 + return Ok((did.to_string(), rkey.to_string())); 224 + } 225 + Ok((default_did.to_string(), id.to_string())) 226 + }
+174 -27
crates/tangled-cli/src/commands/knot.rs
··· 1 - use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs}; 1 + use crate::cli::{Cli, KnotCommand, KnotMigrateArgs}; 2 + use anyhow::anyhow; 2 3 use anyhow::Result; 4 + use git2::{Direction, Repository as GitRepository, StatusOptions}; 5 + use std::path::Path; 3 6 4 7 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 5 8 match cmd { 6 - KnotCommand::List(args) => list(args).await, 7 - KnotCommand::Add(args) => add(args).await, 8 - KnotCommand::Verify(args) => verify(args).await, 9 - KnotCommand::SetDefault(args) => set_default(args).await, 10 - KnotCommand::Remove(args) => remove(args).await, 9 + KnotCommand::Migrate(args) => migrate(args).await, 11 10 } 12 11 } 13 12 14 - async fn list(args: KnotListArgs) -> Result<()> { 15 - println!( 16 - "Knot list (stub) public={} owned={}", 17 - args.public, args.owned 18 - ); 19 - Ok(()) 20 - } 13 + async fn migrate(args: KnotMigrateArgs) -> Result<()> { 14 + let session = crate::util::load_session_with_refresh().await?; 15 + // 1) Ensure we're inside a git repository and working tree is clean 16 + let repo = GitRepository::discover(Path::new("."))?; 17 + let mut status_opts = StatusOptions::new(); 18 + status_opts.include_untracked(false).include_ignored(false); 19 + let statuses = repo.statuses(Some(&mut status_opts))?; 20 + if !statuses.is_empty() { 21 + return Err(anyhow!( 22 + "working tree has uncommitted changes; commit/push before migrating" 23 + )); 24 + } 25 + 26 + // 2) Derive current branch and ensure it's pushed to origin 27 + let head = match repo.head() { 28 + Ok(h) => h, 29 + Err(_) => return Err(anyhow!("repository does not have a HEAD")), 30 + }; 31 + let head_oid = head 32 + .target() 33 + .ok_or_else(|| anyhow!("failed to resolve HEAD OID"))?; 34 + let head_name = head.shorthand().unwrap_or(""); 35 + let full_ref = head.name().unwrap_or("").to_string(); 36 + if !full_ref.starts_with("refs/heads/") { 37 + return Err(anyhow!( 38 + "HEAD is detached; please checkout a branch before migrating" 39 + )); 40 + } 41 + let branch = head_name.to_string(); 42 + 43 + let origin = repo.find_remote("origin").or_else(|_| { 44 + repo.remotes().and_then(|rems| { 45 + rems.get(0) 46 + .ok_or(git2::Error::from_str("no remotes configured")) 47 + .and_then(|name| repo.find_remote(name)) 48 + }) 49 + })?; 50 + 51 + // Connect and list remote heads to find refs/heads/<branch> 52 + let mut remote = origin; 53 + remote.connect(Direction::Fetch)?; 54 + let remote_heads = remote.list()?; 55 + let remote_oid = remote_heads 56 + .iter() 57 + .find_map(|h| { 58 + if h.name() == format!("refs/heads/{}", branch) { 59 + Some(h.oid()) 60 + } else { 61 + None 62 + } 63 + }) 64 + .ok_or_else(|| anyhow!("origin does not have branch '{}' — push first", branch))?; 65 + if remote_oid != head_oid { 66 + return Err(anyhow!( 67 + "local {} ({}) != origin {} ({}); please push before migrating", 68 + branch, 69 + head_oid, 70 + branch, 71 + remote_oid 72 + )); 73 + } 74 + 75 + // 3) Parse origin URL to verify repo identity 76 + let origin_url = remote 77 + .url() 78 + .ok_or_else(|| anyhow!("origin has no URL"))? 79 + .to_string(); 80 + let (origin_owner, origin_name, _origin_host) = parse_remote_url(&origin_url) 81 + .ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?; 82 + 83 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 84 + if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@') || origin_name != name 85 + { 86 + return Err(anyhow!( 87 + "repo mismatch: current checkout '{}'/{} != argument '{}'/{}", 88 + origin_owner, 89 + origin_name, 90 + owner, 91 + name 92 + )); 93 + } 94 + 95 + let pds = session 96 + .pds 97 + .clone() 98 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 99 + .unwrap_or_else(|| "https://bsky.social".into()); 100 + let pds_client = tangled_api::TangledClient::new(&pds); 101 + let info = pds_client 102 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 103 + .await?; 21 104 22 - async fn add(args: KnotAddArgs) -> Result<()> { 105 + // Build a publicly accessible source URL on tangled.org for the existing repo 106 + let owner_path = if owner.starts_with('@') { 107 + owner.to_string() 108 + } else { 109 + format!("@{}", owner) 110 + }; 111 + let source = if args.https { 112 + format!("https://tangled.org/{}/{}", owner_path, name) 113 + } else { 114 + format!( 115 + "git@{}:{}/{}", 116 + info.knot, 117 + owner.trim_start_matches('@'), 118 + name 119 + ) 120 + }; 121 + 122 + // Create the repo on the target knot, seeding from source 123 + let client = tangled_api::TangledClient::default(); 124 + let opts = tangled_api::client::CreateRepoOptions { 125 + did: &session.did, 126 + name: &name, 127 + knot: &args.to, 128 + description: info.description.as_deref(), 129 + default_branch: None, 130 + source: Some(&source), 131 + pds_base: &pds, 132 + access_jwt: &session.access_jwt, 133 + }; 134 + client.create_repo(opts).await?; 135 + 136 + // Update the PDS record to point to the new knot 137 + if args.update_record { 138 + client 139 + .update_repo_knot( 140 + &session.did, 141 + &info.rkey, 142 + &args.to, 143 + &pds, 144 + &session.access_jwt, 145 + ) 146 + .await?; 147 + } 148 + 149 + println!("Migrated repo '{}' to knot {}", name, args.to); 23 150 println!( 24 - "Knot add (stub) url={} did={:?} name={:?} verify={}", 25 - args.url, args.did, args.name, args.verify 151 + "Note: old repository on {} is not deleted automatically.", 152 + info.knot 26 153 ); 27 154 Ok(()) 28 155 } 29 156 30 - async fn verify(args: KnotVerifyArgs) -> Result<()> { 31 - println!("Knot verify (stub) url={}", args.url); 32 - Ok(()) 33 - } 34 - 35 - async fn set_default(args: KnotRefArgs) -> Result<()> { 36 - println!("Knot set-default (stub) url={}", args.url); 37 - Ok(()) 157 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 158 + if let Some((owner, name)) = spec.split_once('/') { 159 + (owner, name.to_string()) 160 + } else { 161 + (default_owner, spec.to_string()) 162 + } 38 163 } 39 164 40 - async fn remove(args: KnotRefArgs) -> Result<()> { 41 - println!("Knot remove (stub) url={}", args.url); 42 - Ok(()) 165 + fn parse_remote_url(url: &str) -> Option<(String, String, String)> { 166 + // Returns (owner, name, host) 167 + if let Some(rest) = url.strip_prefix("git@") { 168 + // git@host:owner/name(.git) 169 + let mut parts = rest.split(':'); 170 + let host = parts.next()?.to_string(); 171 + let path = parts.next()?; 172 + let mut segs = path.trim_end_matches(".git").split('/'); 173 + let owner = segs.next()?.to_string(); 174 + let name = segs.next()?.to_string(); 175 + return Some((owner, name, host)); 176 + } 177 + if url.starts_with("http://") || url.starts_with("https://") { 178 + if let Ok(parsed) = url::Url::parse(url) { 179 + let host = parsed.host_str().unwrap_or("").to_string(); 180 + let path = parsed.path().trim_matches('/'); 181 + // paths may be like '@owner/name' or 'owner/name' 182 + let mut segs = path.trim_end_matches(".git").split('/'); 183 + let first = segs.next()?; 184 + let owner = first.trim_start_matches('@').to_string(); 185 + let name = segs.next()?.to_string(); 186 + return Some((owner, name, host)); 187 + } 188 + } 189 + None 43 190 }
+518 -17
crates/tangled-cli/src/commands/pr.rs
··· 1 1 use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs}; 2 - use anyhow::Result; 2 + use anyhow::{anyhow, Result}; 3 + use std::path::Path; 4 + use std::process::Command; 3 5 4 6 pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { 5 7 match cmd { ··· 12 14 } 13 15 14 16 async fn list(args: PrListArgs) -> Result<()> { 15 - println!( 16 - "PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}", 17 - args.repo, args.state, args.author, args.reviewer 18 - ); 17 + let session = crate::util::load_session_with_refresh().await?; 18 + let pds = session 19 + .pds 20 + .clone() 21 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 + .unwrap_or_else(|| "https://bsky.social".into()); 23 + let client = tangled_api::TangledClient::new(&pds); 24 + let target_repo_at = if let Some(repo) = &args.repo { 25 + let (owner, name) = parse_repo_ref(repo, &session.handle); 26 + let info = client 27 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 28 + .await?; 29 + Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) 30 + } else { 31 + None 32 + }; 33 + let pulls = client 34 + .list_pulls( 35 + &session.did, 36 + target_repo_at.as_deref(), 37 + Some(session.access_jwt.as_str()), 38 + ) 39 + .await?; 40 + if pulls.is_empty() { 41 + println!("No pull requests found (showing only those you created)"); 42 + } else { 43 + println!("RKEY\tTITLE\tTARGET"); 44 + for pr in pulls { 45 + println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 46 + } 47 + } 19 48 Ok(()) 20 49 } 21 50 22 51 async fn create(args: PrCreateArgs) -> Result<()> { 52 + // Must be run inside the repo checkout; we will use git format-patch to build the patch 53 + let session = crate::util::load_session_with_refresh().await?; 54 + let pds = session 55 + .pds 56 + .clone() 57 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 58 + .unwrap_or_else(|| "https://bsky.social".into()); 59 + let client = tangled_api::TangledClient::new(&pds); 60 + 61 + let repo = args 62 + .repo 63 + .as_ref() 64 + .ok_or_else(|| anyhow!("--repo is required for pr create"))?; 65 + let (owner, name) = parse_repo_ref(repo, ""); 66 + let info = client 67 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 68 + .await?; 69 + 70 + let base = args 71 + .base 72 + .as_deref() 73 + .ok_or_else(|| anyhow!("--base is required (target branch)"))?; 74 + let head = args 75 + .head 76 + .as_deref() 77 + .ok_or_else(|| anyhow!("--head is required (source range/branch)"))?; 78 + 79 + // Generate format-patch using external git for fidelity 80 + let output = Command::new("git") 81 + .arg("format-patch") 82 + .arg("--stdout") 83 + .arg(format!("{}..{}", base, head)) 84 + .current_dir(Path::new(".")) 85 + .output()?; 86 + if !output.status.success() { 87 + return Err(anyhow!("failed to run git format-patch")); 88 + } 89 + let patch = String::from_utf8_lossy(&output.stdout).to_string(); 90 + if patch.trim().is_empty() { 91 + return Err(anyhow!("no changes between base and head")); 92 + } 93 + 94 + let title_buf; 95 + let title = if let Some(t) = args.title.as_deref() { 96 + t 97 + } else { 98 + title_buf = format!("{} -> {}", head, base); 99 + &title_buf 100 + }; 101 + let rkey = client 102 + .create_pull( 103 + &session.did, 104 + &info.did, 105 + &info.rkey, 106 + base, 107 + &patch, 108 + title, 109 + args.body.as_deref(), 110 + &pds, 111 + &session.access_jwt, 112 + ) 113 + .await?; 23 114 println!( 24 - "PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}", 25 - args.repo, args.base, args.head, args.title, args.draft 115 + "Created PR rkey={} targeting {} branch {}", 116 + rkey, info.did, base 26 117 ); 27 118 Ok(()) 28 119 } 29 120 30 121 async fn show(args: PrShowArgs) -> Result<()> { 31 - println!( 32 - "PR show (stub) id={} diff={} comments={} checks={}", 33 - args.id, args.diff, args.comments, args.checks 34 - ); 122 + let session = crate::util::load_session_with_refresh().await?; 123 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 124 + let pds = session 125 + .pds 126 + .clone() 127 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 128 + .unwrap_or_else(|| "https://bsky.social".into()); 129 + let client = tangled_api::TangledClient::new(&pds); 130 + let pr = client 131 + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 132 + .await?; 133 + println!("TITLE: {}", pr.title); 134 + if !pr.body.is_empty() { 135 + println!("BODY:\n{}", pr.body); 136 + } 137 + println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); 138 + if args.diff { 139 + println!("PATCH:\n{}", pr.patch); 140 + } 35 141 Ok(()) 36 142 } 37 143 38 144 async fn review(args: PrReviewArgs) -> Result<()> { 39 - println!( 40 - "PR review (stub) id={} approve={} request_changes={} comment={:?}", 41 - args.id, args.approve, args.request_changes, args.comment 42 - ); 145 + let session = crate::util::load_session_with_refresh().await?; 146 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 147 + let pds = session 148 + .pds 149 + .clone() 150 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 151 + .unwrap_or_else(|| "https://bsky.social".into()); 152 + let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey); 153 + let note = if let Some(c) = args.comment.as_deref() { 154 + c 155 + } else if args.approve { 156 + "LGTM" 157 + } else if args.request_changes { 158 + "Requesting changes" 159 + } else { 160 + "" 161 + }; 162 + if note.is_empty() { 163 + return Err(anyhow!("provide --comment or --approve/--request-changes")); 164 + } 165 + let client = tangled_api::TangledClient::new(&pds); 166 + client 167 + .comment_pull(&session.did, &pr_at, note, &pds, &session.access_jwt) 168 + .await?; 169 + println!("Review comment posted"); 43 170 Ok(()) 44 171 } 45 172 46 173 async fn merge(args: PrMergeArgs) -> Result<()> { 174 + let session = crate::util::load_session_with_refresh().await?; 175 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 176 + let pds = session 177 + .pds 178 + .clone() 179 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 180 + .unwrap_or_else(|| "https://bsky.social".into()); 181 + 182 + // Get the PR 183 + let pds_client = tangled_api::TangledClient::new(&pds); 184 + let pull = pds_client 185 + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 186 + .await?; 187 + 188 + // Parse target repo info 189 + let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?; 190 + 191 + // Check if PR is part of a stack 192 + if let Some(stack_id) = &pull.stack_id { 193 + merge_stacked_pr( 194 + &pds_client, 195 + &session, 196 + &pull, 197 + &did, 198 + &rkey, 199 + &repo_did, 200 + &repo_name, 201 + stack_id, 202 + &pds, 203 + ) 204 + .await?; 205 + } else { 206 + // Single PR merge (existing logic) 207 + merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?; 208 + } 209 + 210 + Ok(()) 211 + } 212 + 213 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 214 + if let Some((owner, name)) = spec.split_once('/') { 215 + if !owner.is_empty() { 216 + (owner, name) 217 + } else { 218 + (default_owner, name) 219 + } 220 + } else { 221 + (default_owner, spec) 222 + } 223 + } 224 + 225 + fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> { 226 + if let Some(rest) = id.strip_prefix("at://") { 227 + let parts: Vec<&str> = rest.split('/').collect(); 228 + if parts.len() >= 4 { 229 + return Ok((parts[0].to_string(), parts[3].to_string())); 230 + } 231 + } 232 + if let Some((did, rkey)) = id.split_once(':') { 233 + return Ok((did.to_string(), rkey.to_string())); 234 + } 235 + Ok((default_did.to_string(), id.to_string())) 236 + } 237 + 238 + // Helper functions for stacked PR merge support 239 + 240 + async fn merge_single_pr( 241 + session: &tangled_config::session::Session, 242 + did: &str, 243 + rkey: &str, 244 + repo_did: &str, 245 + repo_name: &str, 246 + pds: &str, 247 + ) -> Result<()> { 248 + let api = tangled_api::TangledClient::default(); 249 + api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt) 250 + .await?; 251 + 252 + println!("Merged PR {}:{}", did, rkey); 253 + Ok(()) 254 + } 255 + 256 + async fn merge_stacked_pr( 257 + pds_client: &tangled_api::TangledClient, 258 + session: &tangled_config::session::Session, 259 + current_pull: &tangled_api::Pull, 260 + current_did: &str, 261 + current_rkey: &str, 262 + repo_did: &str, 263 + repo_name: &str, 264 + stack_id: &str, 265 + pds: &str, 266 + ) -> Result<()> { 267 + // Step 1: Get full stack 268 + println!("🔍 Detecting stack..."); 269 + let stack = get_stack_pulls(pds_client, &session.did, stack_id, &session.access_jwt).await?; 270 + 271 + if stack.is_empty() { 272 + return Err(anyhow!("Stack is empty")); 273 + } 274 + 275 + // Step 2: Find substack (current PR and all below it) 276 + let substack = find_substack(&stack, current_pull.change_id.as_deref())?; 277 + 47 278 println!( 48 - "PR merge (stub) id={} squash={} rebase={} no_ff={}", 49 - args.id, args.squash, args.rebase, args.no_ff 279 + "✓ Detected PR is part of stack (stack has {} total PRs)", 280 + stack.len() 50 281 ); 282 + println!(); 283 + println!("The following {} PR(s) will be merged:", substack.len()); 284 + 285 + for (idx, pr) in substack.iter().enumerate() { 286 + let marker = if pr.rkey == current_rkey { 287 + " (current)" 288 + } else { 289 + "" 290 + }; 291 + println!(" [{}] {}: {}{}", idx + 1, pr.rkey, pr.pull.title, marker); 292 + } 293 + println!(); 294 + 295 + // Step 3: Check for conflicts 296 + println!("✓ Checking for conflicts..."); 297 + let api = tangled_api::TangledClient::default(); 298 + let conflicts = check_stack_conflicts( 299 + &api, 300 + repo_did, 301 + repo_name, 302 + &current_pull.target.branch, 303 + &substack, 304 + pds, 305 + &session.access_jwt, 306 + ) 307 + .await?; 308 + 309 + if !conflicts.is_empty() { 310 + println!("✗ Cannot merge: conflicts detected"); 311 + println!(); 312 + for (pr_rkey, conflict_resp) in conflicts { 313 + println!( 314 + " PR {}: Conflicts in {} file(s)", 315 + pr_rkey, 316 + conflict_resp.conflicts.len() 317 + ); 318 + for conflict in conflict_resp.conflicts { 319 + println!(" - {}: {}", conflict.filename, conflict.reason); 320 + } 321 + } 322 + return Err(anyhow!("Stack has merge conflicts")); 323 + } 324 + 325 + println!("✓ All PRs can be merged cleanly"); 326 + println!(); 327 + 328 + // Step 4: Confirmation prompt 329 + if !prompt_confirmation(&format!("Merge {} pull request(s)?", substack.len()))? { 330 + println!("Merge cancelled."); 331 + return Ok(()); 332 + } 333 + 334 + // Step 5: Merge the stack (backend handles combined patch) 335 + println!("Merging {} PR(s)...", substack.len()); 336 + 337 + // Use the current PR's merge endpoint - backend will handle the stack 338 + api.merge_pull( 339 + current_did, 340 + current_rkey, 341 + repo_did, 342 + repo_name, 343 + pds, 344 + &session.access_jwt, 345 + ) 346 + .await?; 347 + 348 + println!("✓ Successfully merged {} pull request(s)", substack.len()); 349 + 51 350 Ok(()) 52 351 } 352 + 353 + async fn get_stack_pulls( 354 + client: &tangled_api::TangledClient, 355 + user_did: &str, 356 + stack_id: &str, 357 + bearer: &str, 358 + ) -> Result<Vec<tangled_api::PullRecord>> { 359 + // List all user's PRs and filter by stack_id 360 + let all_pulls = client.list_pulls(user_did, None, Some(bearer)).await?; 361 + 362 + let mut stack_pulls: Vec<_> = all_pulls 363 + .into_iter() 364 + .filter(|p| p.pull.stack_id.as_deref() == Some(stack_id)) 365 + .collect(); 366 + 367 + // Order by parent relationships (top to bottom) 368 + order_stack(&mut stack_pulls)?; 369 + 370 + Ok(stack_pulls) 371 + } 372 + 373 + fn order_stack(pulls: &mut Vec<tangled_api::PullRecord>) -> Result<()> { 374 + if pulls.is_empty() { 375 + return Ok(()); 376 + } 377 + 378 + // Build parent map: parent_change_id -> pull 379 + let mut change_id_map: std::collections::HashMap<String, usize> = 380 + std::collections::HashMap::new(); 381 + let mut parent_map: std::collections::HashMap<String, usize> = 382 + std::collections::HashMap::new(); 383 + 384 + for (idx, pr) in pulls.iter().enumerate() { 385 + if let Some(cid) = &pr.pull.change_id { 386 + change_id_map.insert(cid.clone(), idx); 387 + } 388 + if let Some(pcid) = &pr.pull.parent_change_id { 389 + parent_map.insert(pcid.clone(), idx); 390 + } 391 + } 392 + 393 + // Find top of stack (not a parent of any other PR) 394 + let mut top_idx = None; 395 + for (idx, pr) in pulls.iter().enumerate() { 396 + if let Some(cid) = &pr.pull.change_id { 397 + if !parent_map.contains_key(cid) { 398 + top_idx = Some(idx); 399 + break; 400 + } 401 + } 402 + } 403 + 404 + let top_idx = top_idx.ok_or_else(|| anyhow!("Could not find top of stack"))?; 405 + 406 + // Walk down the stack to build ordered list 407 + let mut ordered = Vec::new(); 408 + let mut current_idx = top_idx; 409 + let mut visited = std::collections::HashSet::new(); 410 + 411 + loop { 412 + if visited.contains(&current_idx) { 413 + return Err(anyhow!("Circular dependency in stack")); 414 + } 415 + visited.insert(current_idx); 416 + ordered.push(current_idx); 417 + 418 + // Find child (PR that has this PR as parent) 419 + let current_parent = &pulls[current_idx].pull.parent_change_id; 420 + if current_parent.is_none() { 421 + break; 422 + } 423 + 424 + let next_idx = change_id_map.get(current_parent.as_ref().unwrap()); 425 + 426 + if let Some(&next) = next_idx { 427 + current_idx = next; 428 + } else { 429 + break; 430 + } 431 + } 432 + 433 + // Reorder pulls based on ordered indices 434 + let original = pulls.clone(); 435 + pulls.clear(); 436 + for idx in ordered { 437 + pulls.push(original[idx].clone()); 438 + } 439 + 440 + Ok(()) 441 + } 442 + 443 + fn find_substack<'a>( 444 + stack: &'a [tangled_api::PullRecord], 445 + current_change_id: Option<&str>, 446 + ) -> Result<Vec<&'a tangled_api::PullRecord>> { 447 + let change_id = current_change_id.ok_or_else(|| anyhow!("PR has no change_id"))?; 448 + 449 + let position = stack 450 + .iter() 451 + .position(|p| p.pull.change_id.as_deref() == Some(change_id)) 452 + .ok_or_else(|| anyhow!("PR not found in stack"))?; 453 + 454 + // Return from current position to end (including current) 455 + Ok(stack[position..].iter().collect()) 456 + } 457 + 458 + async fn check_stack_conflicts( 459 + api: &tangled_api::TangledClient, 460 + repo_did: &str, 461 + repo_name: &str, 462 + target_branch: &str, 463 + substack: &[&tangled_api::PullRecord], 464 + pds: &str, 465 + access_jwt: &str, 466 + ) -> Result<Vec<(String, tangled_api::MergeCheckResponse)>> { 467 + let mut conflicts = Vec::new(); 468 + let mut cumulative_patch = String::new(); 469 + 470 + // Check each PR in order (bottom to top of substack) 471 + for pr in substack.iter().rev() { 472 + cumulative_patch.push_str(&pr.pull.patch); 473 + cumulative_patch.push('\n'); 474 + 475 + let check = api 476 + .merge_check( 477 + repo_did, 478 + repo_name, 479 + target_branch, 480 + &cumulative_patch, 481 + pds, 482 + access_jwt, 483 + ) 484 + .await?; 485 + 486 + if check.is_conflicted { 487 + conflicts.push((pr.rkey.clone(), check)); 488 + } 489 + } 490 + 491 + Ok(conflicts) 492 + } 493 + 494 + fn prompt_confirmation(message: &str) -> Result<bool> { 495 + use std::io::{self, Write}; 496 + 497 + print!("{} [y/N]: ", message); 498 + io::stdout().flush()?; 499 + 500 + let mut input = String::new(); 501 + io::stdin().read_line(&mut input)?; 502 + 503 + Ok(matches!( 504 + input.trim().to_lowercase().as_str(), 505 + "y" | "yes" 506 + )) 507 + } 508 + 509 + async fn parse_target_repo_info( 510 + pull: &tangled_api::Pull, 511 + pds_client: &tangled_api::TangledClient, 512 + session: &tangled_config::session::Session, 513 + ) -> Result<(String, String)> { 514 + let target_repo = &pull.target.repo; 515 + let parts: Vec<&str> = target_repo 516 + .strip_prefix("at://") 517 + .unwrap_or(target_repo) 518 + .split('/') 519 + .collect(); 520 + 521 + if parts.len() < 4 { 522 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 523 + } 524 + 525 + let repo_did = parts[0].to_string(); 526 + let repo_rkey = parts[3]; 527 + 528 + // Get repo name 529 + #[derive(serde::Deserialize)] 530 + struct Rec { 531 + name: String, 532 + } 533 + #[derive(serde::Deserialize)] 534 + struct GetRes { 535 + value: Rec, 536 + } 537 + 538 + let params = [ 539 + ("repo", repo_did.clone()), 540 + ("collection", "sh.tangled.repo".to_string()), 541 + ("rkey", repo_rkey.to_string()), 542 + ]; 543 + 544 + let repo_rec: GetRes = pds_client 545 + .get_json( 546 + "com.atproto.repo.getRecord", 547 + &params, 548 + Some(&session.access_jwt), 549 + ) 550 + .await?; 551 + 552 + Ok((repo_did, repo_rec.value.name)) 553 + }
+185 -23
crates/tangled-cli/src/commands/repo.rs
··· 1 1 use anyhow::{anyhow, Result}; 2 + use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks}; 2 3 use serde_json; 3 - use tangled_config::session::SessionManager; 4 + use std::path::PathBuf; 4 5 5 6 use crate::cli::{ 6 7 Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs, ··· 20 21 } 21 22 22 23 async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> { 23 - let mgr = SessionManager::default(); 24 - let session = match mgr.load()? { 25 - Some(s) => s, 26 - None => return Err(anyhow!("Please login first: tangled auth login")), 27 - }; 24 + let session = crate::util::load_session_with_refresh().await?; 28 25 29 26 // Use the PDS to list repo records for the user 30 27 let pds = session ··· 61 58 } 62 59 63 60 async fn create(args: RepoCreateArgs) -> Result<()> { 64 - let mgr = SessionManager::default(); 65 - let session = match mgr.load()? { 66 - Some(s) => s, 67 - None => return Err(anyhow!("Please login first: tangled auth login")), 68 - }; 61 + let session = crate::util::load_session_with_refresh().await?; 69 62 70 63 let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into()); 71 64 let client = tangled_api::TangledClient::new(base); ··· 95 88 } 96 89 97 90 async fn clone(args: RepoCloneArgs) -> Result<()> { 98 - println!( 99 - "Cloning repo '{}' (stub) https={} depth={:?}", 100 - args.repo, args.https, args.depth 101 - ); 102 - Ok(()) 91 + let session = crate::util::load_session_with_refresh().await?; 92 + 93 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 94 + let pds = session 95 + .pds 96 + .clone() 97 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 98 + .unwrap_or_else(|| "https://bsky.social".into()); 99 + let pds_client = tangled_api::TangledClient::new(&pds); 100 + let info = pds_client 101 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 102 + .await?; 103 + 104 + let remote = if args.https { 105 + let owner_path = if owner.starts_with('@') { 106 + owner.to_string() 107 + } else { 108 + format!("@{}", owner) 109 + }; 110 + format!("https://tangled.org/{}/{}", owner_path, name) 111 + } else { 112 + let knot = if info.knot == "knot1.tangled.sh" { 113 + "tangled.org".to_string() 114 + } else { 115 + info.knot.clone() 116 + }; 117 + format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name) 118 + }; 119 + 120 + let target = PathBuf::from(&name); 121 + println!("Cloning {} -> {:?}", remote, target); 122 + 123 + let mut callbacks = RemoteCallbacks::new(); 124 + callbacks.credentials(|_url, username_from_url, _allowed| { 125 + if let Some(user) = username_from_url { 126 + Cred::ssh_key_from_agent(user) 127 + } else { 128 + Cred::default() 129 + } 130 + }); 131 + let mut fetch_opts = FetchOptions::new(); 132 + fetch_opts.remote_callbacks(callbacks); 133 + if let Some(d) = args.depth { 134 + fetch_opts.depth(d as i32); 135 + } 136 + let mut builder = RepoBuilder::new(); 137 + builder.fetch_options(fetch_opts); 138 + match builder.clone(&remote, &target) { 139 + Ok(_) => Ok(()), 140 + Err(e) => { 141 + println!("Failed to clone via libgit2: {}", e); 142 + println!( 143 + "Hint: try: git clone{} {}", 144 + args.depth 145 + .map(|d| format!(" --depth {}", d)) 146 + .unwrap_or_default(), 147 + remote 148 + ); 149 + Err(anyhow!(e.to_string())) 150 + } 151 + } 103 152 } 104 153 105 154 async fn info(args: RepoInfoArgs) -> Result<()> { 106 - println!( 107 - "Repository info '{}' (stub) stats={} contributors={}", 108 - args.repo, args.stats, args.contributors 109 - ); 155 + let session = crate::util::load_session_with_refresh().await?; 156 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 157 + let pds = session 158 + .pds 159 + .clone() 160 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 161 + .unwrap_or_else(|| "https://bsky.social".into()); 162 + let pds_client = tangled_api::TangledClient::new(&pds); 163 + let info = pds_client 164 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 165 + .await?; 166 + 167 + println!("NAME: {}", info.name); 168 + println!("OWNER DID: {}", info.did); 169 + println!("KNOT: {}", info.knot); 170 + if let Some(spindle) = info.spindle.as_deref() { 171 + if !spindle.is_empty() { 172 + println!("SPINDLE: {}", spindle); 173 + } 174 + } 175 + if let Some(desc) = info.description.as_deref() { 176 + if !desc.is_empty() { 177 + println!("DESCRIPTION: {}", desc); 178 + } 179 + } 180 + 181 + let knot_host = if info.knot == "knot1.tangled.sh" { 182 + "tangled.org".to_string() 183 + } else { 184 + info.knot.clone() 185 + }; 186 + if args.stats { 187 + let client = tangled_api::TangledClient::default(); 188 + if let Ok(def) = client 189 + .get_default_branch(&knot_host, &info.did, &info.name) 190 + .await 191 + { 192 + println!( 193 + "DEFAULT BRANCH: {} ({})", 194 + def.name, 195 + def.short_hash.unwrap_or(def.hash) 196 + ); 197 + if let Some(msg) = def.message { 198 + if !msg.is_empty() { 199 + println!("LAST COMMIT: {}", msg); 200 + } 201 + } 202 + } 203 + if let Ok(langs) = client 204 + .get_languages(&knot_host, &info.did, &info.name) 205 + .await 206 + { 207 + if !langs.languages.is_empty() { 208 + println!("LANGUAGES:"); 209 + for l in langs.languages.iter().take(6) { 210 + println!(" - {} ({}%)", l.name, l.percentage); 211 + } 212 + } 213 + } 214 + } 215 + 216 + if args.contributors { 217 + println!("Contributors: not implemented yet"); 218 + } 110 219 Ok(()) 111 220 } 112 221 113 222 async fn delete(args: RepoDeleteArgs) -> Result<()> { 114 - println!("Deleting repo '{}' (stub) force={}", args.repo, args.force); 223 + let session = crate::util::load_session_with_refresh().await?; 224 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 225 + let pds = session 226 + .pds 227 + .clone() 228 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 229 + .unwrap_or_else(|| "https://bsky.social".into()); 230 + let pds_client = tangled_api::TangledClient::new(&pds); 231 + let record = pds_client 232 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 233 + .await?; 234 + let did = record.did; 235 + let api = tangled_api::TangledClient::default(); 236 + api.delete_repo(&did, &name, &pds, &session.access_jwt) 237 + .await?; 238 + println!("Deleted repo '{}'", name); 115 239 Ok(()) 116 240 } 117 241 118 242 async fn star(args: RepoRefArgs) -> Result<()> { 119 - println!("Starring repo '{}' (stub)", args.repo); 243 + let session = crate::util::load_session_with_refresh().await?; 244 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 245 + let pds = session 246 + .pds 247 + .clone() 248 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 249 + .unwrap_or_else(|| "https://bsky.social".into()); 250 + let pds_client = tangled_api::TangledClient::new(&pds); 251 + let info = pds_client 252 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 253 + .await?; 254 + let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 255 + let api = tangled_api::TangledClient::default(); 256 + api.star_repo(&pds, &session.access_jwt, &subject, &session.did) 257 + .await?; 258 + println!("Starred {}/{}", owner, name); 120 259 Ok(()) 121 260 } 122 261 123 262 async fn unstar(args: RepoRefArgs) -> Result<()> { 124 - println!("Unstarring repo '{}' (stub)", args.repo); 263 + let session = crate::util::load_session_with_refresh().await?; 264 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 265 + let pds = session 266 + .pds 267 + .clone() 268 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 269 + .unwrap_or_else(|| "https://bsky.social".into()); 270 + let pds_client = tangled_api::TangledClient::new(&pds); 271 + let info = pds_client 272 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 273 + .await?; 274 + let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 275 + let api = tangled_api::TangledClient::default(); 276 + api.unstar_repo(&pds, &session.access_jwt, &subject, &session.did) 277 + .await?; 278 + println!("Unstarred {}/{}", owner, name); 125 279 Ok(()) 126 280 } 281 + 282 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 283 + if let Some((owner, name)) = spec.split_once('/') { 284 + (owner, name.to_string()) 285 + } else { 286 + (default_owner, spec.to_string()) 287 + } 288 + }
+284 -9
crates/tangled-cli/src/commands/spindle.rs
··· 1 1 use crate::cli::{ 2 2 Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs, 3 + SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs, 3 4 }; 4 - use anyhow::Result; 5 + use anyhow::{anyhow, Result}; 6 + use futures_util::StreamExt; 7 + use tokio_tungstenite::{connect_async, tungstenite::Message}; 5 8 6 9 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { 7 10 match cmd { ··· 9 12 SpindleCommand::Config(args) => config(args).await, 10 13 SpindleCommand::Run(args) => run_pipeline(args).await, 11 14 SpindleCommand::Logs(args) => logs(args).await, 15 + SpindleCommand::Secret(cmd) => secret(cmd).await, 12 16 } 13 17 } 14 18 15 19 async fn list(args: SpindleListArgs) -> Result<()> { 16 - println!("Spindle list (stub) repo={:?}", args.repo); 20 + let session = crate::util::load_session_with_refresh().await?; 21 + 22 + let pds = session 23 + .pds 24 + .clone() 25 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 26 + .unwrap_or_else(|| "https://bsky.social".into()); 27 + let pds_client = tangled_api::TangledClient::new(&pds); 28 + 29 + let (owner, name) = parse_repo_ref( 30 + args.repo.as_deref().unwrap_or(&session.handle), 31 + &session.handle 32 + ); 33 + let info = pds_client 34 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 35 + .await?; 36 + 37 + let pipelines = pds_client 38 + .list_pipelines(&info.did, Some(session.access_jwt.as_str())) 39 + .await?; 40 + 41 + if pipelines.is_empty() { 42 + println!("No pipelines found for {}/{}", owner, name); 43 + } else { 44 + println!("RKEY\tKIND\tREPO\tWORKFLOWS"); 45 + for p in pipelines { 46 + let workflows = p.pipeline.workflows 47 + .iter() 48 + .map(|w| w.name.as_str()) 49 + .collect::<Vec<_>>() 50 + .join(","); 51 + println!( 52 + "{}\t{}\t{}\t{}", 53 + p.rkey, 54 + p.pipeline.trigger_metadata.kind, 55 + p.pipeline.trigger_metadata.repo.repo, 56 + workflows 57 + ); 58 + } 59 + } 17 60 Ok(()) 18 61 } 19 62 20 63 async fn config(args: SpindleConfigArgs) -> Result<()> { 21 - println!( 22 - "Spindle config (stub) repo={:?} url={:?} enable={} disable={}", 23 - args.repo, args.url, args.enable, args.disable 64 + let session = crate::util::load_session_with_refresh().await?; 65 + 66 + if args.enable && args.disable { 67 + return Err(anyhow!("Cannot use --enable and --disable together")); 68 + } 69 + 70 + if !args.enable && !args.disable && args.url.is_none() { 71 + return Err(anyhow!( 72 + "Must provide --enable, --disable, or --url" 73 + )); 74 + } 75 + 76 + let pds = session 77 + .pds 78 + .clone() 79 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 80 + .unwrap_or_else(|| "https://bsky.social".into()); 81 + let pds_client = tangled_api::TangledClient::new(&pds); 82 + 83 + let (owner, name) = parse_repo_ref( 84 + args.repo.as_deref().unwrap_or(&session.handle), 85 + &session.handle 24 86 ); 87 + let info = pds_client 88 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 89 + .await?; 90 + 91 + let new_spindle = if args.disable { 92 + None 93 + } else if let Some(url) = args.url.as_deref() { 94 + Some(url) 95 + } else if args.enable { 96 + // Default spindle URL 97 + Some("https://spindle.tangled.sh") 98 + } else { 99 + return Err(anyhow!("Invalid flags combination")); 100 + }; 101 + 102 + pds_client 103 + .update_repo_spindle(&info.did, &info.rkey, new_spindle, &pds, &session.access_jwt) 104 + .await?; 105 + 106 + if args.disable { 107 + println!("Disabled spindle for {}/{}", owner, name); 108 + } else { 109 + println!( 110 + "Enabled spindle for {}/{} ({})", 111 + owner, 112 + name, 113 + new_spindle.unwrap_or_default() 114 + ); 115 + } 25 116 Ok(()) 26 117 } 27 118 ··· 34 125 } 35 126 36 127 async fn logs(args: SpindleLogsArgs) -> Result<()> { 37 - println!( 38 - "Spindle logs (stub) job_id={} follow={} lines={:?}", 39 - args.job_id, args.follow, args.lines 40 - ); 128 + // Parse job_id: format is "knot:rkey:name" or just "name" (use repo context) 129 + let parts: Vec<&str> = args.job_id.split(':').collect(); 130 + let (knot, rkey, name) = if parts.len() == 3 { 131 + (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) 132 + } else if parts.len() == 1 { 133 + // Use repo context - need to get repo info 134 + let session = crate::util::load_session_with_refresh().await?; 135 + let pds = session 136 + .pds 137 + .clone() 138 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 139 + .unwrap_or_else(|| "https://bsky.social".into()); 140 + let pds_client = tangled_api::TangledClient::new(&pds); 141 + // Get repo info from current directory context or default to user's handle 142 + let info = pds_client 143 + .get_repo_info(&session.handle, &session.handle, Some(session.access_jwt.as_str())) 144 + .await?; 145 + (info.knot, info.rkey, parts[0].to_string()) 146 + } else { 147 + return Err(anyhow!("Invalid job_id format. Expected 'knot:rkey:name' or 'name'")); 148 + }; 149 + 150 + // Build WebSocket URL - spindle base is typically https://spindle.tangled.sh 151 + let spindle_base = std::env::var("TANGLED_SPINDLE_BASE") 152 + .unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string()); 153 + let ws_url = format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name); 154 + 155 + println!("Connecting to logs stream for {}:{}:{}...", knot, rkey, name); 156 + 157 + // Connect to WebSocket 158 + let (ws_stream, _) = connect_async(&ws_url).await 159 + .map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?; 160 + 161 + let (mut _write, mut read) = ws_stream.split(); 162 + 163 + // Stream log messages 164 + let mut line_count = 0; 165 + let max_lines = args.lines.unwrap_or(usize::MAX); 166 + 167 + while let Some(msg) = read.next().await { 168 + match msg { 169 + Ok(Message::Text(text)) => { 170 + println!("{}", text); 171 + line_count += 1; 172 + if line_count >= max_lines { 173 + break; 174 + } 175 + } 176 + Ok(Message::Close(_)) => { 177 + break; 178 + } 179 + Err(e) => { 180 + return Err(anyhow!("WebSocket error: {}", e)); 181 + } 182 + _ => {} 183 + } 184 + } 185 + 186 + Ok(()) 187 + } 188 + 189 + async fn secret(cmd: SpindleSecretCommand) -> Result<()> { 190 + match cmd { 191 + SpindleSecretCommand::List(args) => secret_list(args).await, 192 + SpindleSecretCommand::Add(args) => secret_add(args).await, 193 + SpindleSecretCommand::Remove(args) => secret_remove(args).await, 194 + } 195 + } 196 + 197 + async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 198 + let session = crate::util::load_session_with_refresh().await?; 199 + let pds = session 200 + .pds 201 + .clone() 202 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 203 + .unwrap_or_else(|| "https://bsky.social".into()); 204 + let pds_client = tangled_api::TangledClient::new(&pds); 205 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 206 + let info = pds_client 207 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 208 + .await?; 209 + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 210 + 211 + // Get spindle base from repo config or use default 212 + let spindle_base = info.spindle 213 + .clone() 214 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 215 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 216 + let api = tangled_api::TangledClient::new(&spindle_base); 217 + 218 + let secrets = api 219 + .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 220 + .await?; 221 + if secrets.is_empty() { 222 + println!("No secrets configured for {}", args.repo); 223 + } else { 224 + println!("KEY\tCREATED AT\tCREATED BY"); 225 + for s in secrets { 226 + println!("{}\t{}\t{}", s.key, s.created_at, s.created_by); 227 + } 228 + } 41 229 Ok(()) 42 230 } 231 + 232 + async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 233 + let session = crate::util::load_session_with_refresh().await?; 234 + let pds = session 235 + .pds 236 + .clone() 237 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 238 + .unwrap_or_else(|| "https://bsky.social".into()); 239 + let pds_client = tangled_api::TangledClient::new(&pds); 240 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 241 + let info = pds_client 242 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 243 + .await?; 244 + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 245 + 246 + // Get spindle base from repo config or use default 247 + let spindle_base = info.spindle 248 + .clone() 249 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 250 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 251 + let api = tangled_api::TangledClient::new(&spindle_base); 252 + 253 + // Handle special value patterns: @file or - (stdin) 254 + let value = if args.value == "-" { 255 + // Read from stdin 256 + use std::io::Read; 257 + let mut buffer = String::new(); 258 + std::io::stdin().read_to_string(&mut buffer)?; 259 + buffer 260 + } else if let Some(path) = args.value.strip_prefix('@') { 261 + // Read from file, expand ~ if needed 262 + let expanded_path = if path.starts_with("~/") { 263 + if let Some(home) = std::env::var("HOME").ok() { 264 + path.replacen("~/", &format!("{}/", home), 1) 265 + } else { 266 + path.to_string() 267 + } 268 + } else { 269 + path.to_string() 270 + }; 271 + std::fs::read_to_string(&expanded_path) 272 + .map_err(|e| anyhow!("Failed to read file '{}': {}", expanded_path, e))? 273 + } else { 274 + // Use value as-is 275 + args.value 276 + }; 277 + 278 + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value) 279 + .await?; 280 + println!("Added secret '{}' to {}", args.key, args.repo); 281 + Ok(()) 282 + } 283 + 284 + async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 285 + let session = crate::util::load_session_with_refresh().await?; 286 + let pds = session 287 + .pds 288 + .clone() 289 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 290 + .unwrap_or_else(|| "https://bsky.social".into()); 291 + let pds_client = tangled_api::TangledClient::new(&pds); 292 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 293 + let info = pds_client 294 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 295 + .await?; 296 + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 297 + 298 + // Get spindle base from repo config or use default 299 + let spindle_base = info.spindle 300 + .clone() 301 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 302 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 303 + let api = tangled_api::TangledClient::new(&spindle_base); 304 + 305 + api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 306 + .await?; 307 + println!("Removed secret '{}' from {}", args.key, args.repo); 308 + Ok(()) 309 + } 310 + 311 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 312 + if let Some((owner, name)) = spec.split_once('/') { 313 + (owner, name) 314 + } else { 315 + (default_owner, spec) 316 + } 317 + }
+1
crates/tangled-cli/src/main.rs
··· 1 1 mod cli; 2 2 mod commands; 3 + mod util; 3 4 4 5 use anyhow::Result; 5 6 use clap::Parser;
+55
crates/tangled-cli/src/util.rs
··· 1 + use anyhow::{anyhow, Result}; 2 + use tangled_config::session::{Session, SessionManager}; 3 + 4 + /// Load session and automatically refresh if expired 5 + pub async fn load_session() -> Result<Session> { 6 + let mgr = SessionManager::default(); 7 + let session = mgr 8 + .load()? 9 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 10 + 11 + Ok(session) 12 + } 13 + 14 + /// Refresh the session using the refresh token 15 + pub async fn refresh_session(session: &Session) -> Result<Session> { 16 + let pds = session 17 + .pds 18 + .clone() 19 + .unwrap_or_else(|| "https://bsky.social".to_string()); 20 + 21 + let client = tangled_api::TangledClient::new(&pds); 22 + let mut new_session = client.refresh_session(&session.refresh_jwt).await?; 23 + 24 + // Preserve PDS from old session 25 + new_session.pds = session.pds.clone(); 26 + 27 + // Save the refreshed session 28 + let mgr = SessionManager::default(); 29 + mgr.save(&new_session)?; 30 + 31 + Ok(new_session) 32 + } 33 + 34 + /// Load session with automatic refresh on ExpiredToken 35 + pub async fn load_session_with_refresh() -> Result<Session> { 36 + let session = load_session().await?; 37 + 38 + // Check if session is older than 30 minutes - if so, proactively refresh 39 + let age = chrono::Utc::now() 40 + .signed_duration_since(session.created_at) 41 + .num_minutes(); 42 + 43 + if age > 30 { 44 + // Session is old, proactively refresh 45 + match refresh_session(&session).await { 46 + Ok(new_session) => return Ok(new_session), 47 + Err(_) => { 48 + // If refresh fails, try with the old session anyway 49 + // It might still work 50 + } 51 + } 52 + } 53 + 54 + Ok(session) 55 + }
+303 -7
docs/getting-started.md
··· 1 - # Getting Started 1 + # Getting Started with Tangled CLI 2 + 3 + This guide will help you get up and running with the Tangled CLI. 4 + 5 + ## Installation 6 + 7 + ### Prerequisites 8 + 9 + - Rust toolchain 1.70 or later 10 + - Git 11 + - A Bluesky/AT Protocol account 12 + 13 + ### Build from Source 14 + 15 + 1. Clone the repository: 16 + ```sh 17 + git clone https://tangled.org/tangled/tangled-cli 18 + cd tangled-cli 19 + ``` 20 + 21 + 2. Build the project: 22 + ```sh 23 + cargo build --release 24 + ``` 25 + 26 + 3. The binary will be available at `target/release/tangled-cli`. Optionally, add it to your PATH or create an alias: 27 + ```sh 28 + alias tangled='./target/release/tangled-cli' 29 + ``` 30 + 31 + ### Install from AUR (Arch Linux) 32 + 33 + If you're on Arch Linux, you can install from the AUR: 34 + 35 + ```sh 36 + yay -S tangled-cli-git 37 + ``` 38 + 39 + ## First Steps 40 + 41 + ### 1. Authenticate 42 + 43 + Login with your AT Protocol credentials (your Bluesky account): 44 + 45 + ```sh 46 + tangled auth login 47 + ``` 48 + 49 + You'll be prompted for your handle (e.g., `alice.bsky.social`) and password. If you're using a custom PDS, specify it with the `--pds` flag: 50 + 51 + ```sh 52 + tangled auth login --pds https://your-pds.example.com 53 + ``` 54 + 55 + Your credentials are stored securely in your system keyring. 56 + 57 + ### 2. Check Your Status 58 + 59 + Verify you're logged in: 60 + 61 + ```sh 62 + tangled auth status 63 + ``` 64 + 65 + ### 3. List Your Repositories 66 + 67 + See all your repositories: 68 + 69 + ```sh 70 + tangled repo list 71 + ``` 72 + 73 + Or view someone else's public repositories: 74 + 75 + ```sh 76 + tangled repo list --user alice.bsky.social 77 + ``` 78 + 79 + ### 4. Create a Repository 80 + 81 + Create a new repository on Tangled: 82 + 83 + ```sh 84 + tangled repo create my-project --description "My awesome project" 85 + ``` 86 + 87 + By default, repositories are created on the default knot (`tngl.sh`). You can specify a different knot: 88 + 89 + ```sh 90 + tangled repo create my-project --knot knot1.tangled.sh 91 + ``` 92 + 93 + ### 5. Clone a Repository 94 + 95 + Clone a repository to start working on it: 96 + 97 + ```sh 98 + tangled repo clone alice/my-project 99 + ``` 100 + 101 + This uses SSH by default. For HTTPS: 102 + 103 + ```sh 104 + tangled repo clone alice/my-project --https 105 + ``` 106 + 107 + ## Working with Issues 108 + 109 + ### Create an Issue 110 + 111 + ```sh 112 + tangled issue create --repo my-project --title "Add new feature" --body "We should add feature X" 113 + ``` 114 + 115 + ### List Issues 116 + 117 + ```sh 118 + tangled issue list --repo my-project 119 + ``` 2 120 3 - This project is a scaffold of a Tangled CLI in Rust. The commands are present as stubs and will be wired to XRPC endpoints iteratively. 121 + ### View Issue Details 4 122 5 - ## Build 123 + ```sh 124 + tangled issue show <issue-id> 125 + ``` 6 126 7 - Requires Rust toolchain and network access to fetch dependencies. 127 + ### Comment on an Issue 8 128 129 + ```sh 130 + tangled issue comment <issue-id> --body "I'm working on this!" 9 131 ``` 10 - cargo build 132 + 133 + ## Working with Pull Requests 134 + 135 + ### Create a Pull Request 136 + 137 + ```sh 138 + tangled pr create --repo my-project --base main --head feature-branch --title "Add feature X" 11 139 ``` 12 140 13 - ## Run 141 + The CLI will use `git format-patch` to create a patch from your branch. 14 142 143 + ### List Pull Requests 144 + 145 + ```sh 146 + tangled pr list --repo my-project 15 147 ``` 16 - cargo run -p tangled-cli -- --help 148 + 149 + ### Review a Pull Request 150 + 151 + ```sh 152 + tangled pr review <pr-id> --approve --comment "Looks good!" 17 153 ``` 18 154 155 + Or request changes: 156 + 157 + ```sh 158 + tangled pr review <pr-id> --request-changes --comment "Please fix the tests" 159 + ``` 160 + 161 + ### Merge a Pull Request 162 + 163 + ```sh 164 + tangled pr merge <pr-id> 165 + ``` 166 + 167 + ## CI/CD with Spindle 168 + 169 + Spindle is Tangled's integrated CI/CD system. 170 + 171 + ### Enable Spindle for Your Repository 172 + 173 + ```sh 174 + tangled spindle config --repo my-project --enable 175 + ``` 176 + 177 + Or use a custom spindle URL: 178 + 179 + ```sh 180 + tangled spindle config --repo my-project --url https://my-spindle.example.com 181 + ``` 182 + 183 + ### View Pipeline Runs 184 + 185 + ```sh 186 + tangled spindle list --repo my-project 187 + ``` 188 + 189 + ### Stream Workflow Logs 190 + 191 + ```sh 192 + tangled spindle logs knot:rkey:workflow-name 193 + ``` 194 + 195 + Add `--follow` to tail the logs in real-time. 196 + 197 + ### Manage Secrets 198 + 199 + Add secrets for your CI/CD workflows: 200 + 201 + ```sh 202 + tangled spindle secret add --repo my-project --key API_KEY --value "my-secret-value" 203 + ``` 204 + 205 + List secrets: 206 + 207 + ```sh 208 + tangled spindle secret list --repo my-project 209 + ``` 210 + 211 + Remove a secret: 212 + 213 + ```sh 214 + tangled spindle secret remove --repo my-project --key API_KEY 215 + ``` 216 + 217 + ## Advanced Topics 218 + 219 + ### Repository Migration 220 + 221 + Move a repository to a different knot: 222 + 223 + ```sh 224 + tangled knot migrate --repo my-project --to knot2.tangled.sh 225 + ``` 226 + 227 + This command must be run from within the repository's working directory, and your working tree must be clean and pushed. 228 + 229 + ### Output Formats 230 + 231 + Most commands support JSON output: 232 + 233 + ```sh 234 + tangled repo list --format json 235 + ``` 236 + 237 + ### Quiet and Verbose Modes 238 + 239 + Reduce output: 240 + 241 + ```sh 242 + tangled --quiet repo list 243 + ``` 244 + 245 + Increase verbosity for debugging: 246 + 247 + ```sh 248 + tangled --verbose repo list 249 + ``` 250 + 251 + ## Configuration 252 + 253 + The CLI stores configuration in: 254 + - Linux: `~/.config/tangled/config.toml` 255 + - macOS: `~/Library/Application Support/tangled/config.toml` 256 + - Windows: `%APPDATA%\tangled\config.toml` 257 + 258 + Session credentials are stored securely in your system keyring (GNOME Keyring, KWallet, macOS Keychain, or Windows Credential Manager). 259 + 260 + ### Environment Variables 261 + 262 + - `TANGLED_PDS_BASE` - Override the default PDS (default: `https://bsky.social`) 263 + - `TANGLED_API_BASE` - Override the Tangled API base (default: `https://tngl.sh`) 264 + - `TANGLED_SPINDLE_BASE` - Override the Spindle base (default: `wss://spindle.tangled.sh`) 265 + 266 + ## Troubleshooting 267 + 268 + ### Keyring Issues on Linux 269 + 270 + If you see keyring errors on Linux, ensure you have a secret service running: 271 + 272 + ```sh 273 + # For GNOME 274 + systemctl --user enable --now gnome-keyring-daemon 275 + 276 + # For KDE 277 + # KWallet should start automatically with Plasma 278 + ``` 279 + 280 + ### Authentication Failures 281 + 282 + If authentication fails with your custom PDS: 283 + 284 + ```sh 285 + tangled auth login --pds https://your-pds.example.com 286 + ``` 287 + 288 + Make sure the PDS URL is correct and accessible. 289 + 290 + ### "Repository not found" Errors 291 + 292 + Verify the repository exists and you have access: 293 + 294 + ```sh 295 + tangled repo info owner/reponame 296 + ``` 297 + 298 + ## Getting Help 299 + 300 + For command-specific help, use the `--help` flag: 301 + 302 + ```sh 303 + tangled --help 304 + tangled repo --help 305 + tangled repo create --help 306 + ``` 307 + 308 + ## Next Steps 309 + 310 + - Explore all available commands with `tangled --help` 311 + - Set up CI/CD workflows with `.tangled.yml` in your repository 312 + - Check out the main README for more examples and advanced usage 313 + 314 + Happy collaborating! 🧶