+1
Cargo.lock
+1
Cargo.lock
+87
crates/tangled-api/src/client.rs
+87
crates/tangled-api/src/client.rs
···
149
149
})
150
150
}
151
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
+
152
184
pub async fn list_repos(
153
185
&self,
154
186
user: Option<&str>,
···
1205
1237
Ok(())
1206
1238
}
1207
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
+
1208
1262
pub async fn update_repo_spindle(
1209
1263
&self,
1210
1264
did: &str,
···
1341
1395
pub patch: String,
1342
1396
#[serde(rename = "createdAt")]
1343
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>,
1344
1405
}
1345
1406
1346
1407
#[derive(Debug, Clone)]
···
1348
1409
pub author_did: String,
1349
1410
pub rkey: String,
1350
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,
1351
1438
}
1352
1439
1353
1440
#[derive(Debug, Clone)]
+2
-2
crates/tangled-api/src/lib.rs
+2
-2
crates/tangled-api/src/lib.rs
···
2
2
3
3
pub use client::TangledClient;
4
4
pub use client::{
5
-
CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord,
6
-
RepoRecord, Repository, Secret,
5
+
ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages,
6
+
MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret,
7
7
};
+1
crates/tangled-cli/Cargo.toml
+1
crates/tangled-cli/Cargo.toml
+1
-1
crates/tangled-cli/src/cli.rs
+1
-1
crates/tangled-cli/src/cli.rs
+5
-21
crates/tangled-cli/src/commands/issue.rs
+5
-21
crates/tangled-cli/src/commands/issue.rs
···
4
4
};
5
5
use anyhow::{anyhow, Result};
6
6
use tangled_api::Issue;
7
-
use tangled_config::session::SessionManager;
8
7
9
8
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
10
9
match cmd {
···
17
16
}
18
17
19
18
async fn list(args: IssueListArgs) -> Result<()> {
20
-
let mgr = SessionManager::default();
21
-
let session = mgr
22
-
.load()?
23
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
19
+
let session = crate::util::load_session_with_refresh().await?;
24
20
let pds = session
25
21
.pds
26
22
.clone()
···
57
53
}
58
54
59
55
async fn create(args: IssueCreateArgs) -> Result<()> {
60
-
let mgr = SessionManager::default();
61
-
let session = mgr
62
-
.load()?
63
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
56
+
let session = crate::util::load_session_with_refresh().await?;
64
57
let pds = session
65
58
.pds
66
59
.clone()
···
97
90
98
91
async fn show(args: IssueShowArgs) -> Result<()> {
99
92
// For now, show only accepts at-uri or did:rkey or rkey (for your DID)
100
-
let mgr = SessionManager::default();
101
-
let session = mgr
102
-
.load()?
103
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
93
+
let session = crate::util::load_session_with_refresh().await?;
104
94
let id = args.id;
105
95
let (did, rkey) = parse_record_id(&id, &session.did)?;
106
96
let pds = session
···
129
119
130
120
async fn edit(args: IssueEditArgs) -> Result<()> {
131
121
// Simple edit: fetch existing record and putRecord with new title/body
132
-
let mgr = SessionManager::default();
133
-
let session = mgr
134
-
.load()?
135
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
122
+
let session = crate::util::load_session_with_refresh().await?;
136
123
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
137
124
let pds = session
138
125
.pds
···
183
170
}
184
171
185
172
async fn comment(args: IssueCommentArgs) -> Result<()> {
186
-
let mgr = SessionManager::default();
187
-
let session = mgr
188
-
.load()?
189
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
173
+
let session = crate::util::load_session_with_refresh().await?;
190
174
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
191
175
let pds = session
192
176
.pds
+1
-5
crates/tangled-cli/src/commands/knot.rs
+1
-5
crates/tangled-cli/src/commands/knot.rs
···
3
3
use anyhow::Result;
4
4
use git2::{Direction, Repository as GitRepository, StatusOptions};
5
5
use std::path::Path;
6
-
use tangled_config::session::SessionManager;
7
6
8
7
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
9
8
match cmd {
···
12
11
}
13
12
14
13
async fn migrate(args: KnotMigrateArgs) -> Result<()> {
15
-
let mgr = SessionManager::default();
16
-
let session = mgr
17
-
.load()?
18
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
14
+
let session = crate::util::load_session_with_refresh().await?;
19
15
// 1) Ensure we're inside a git repository and working tree is clean
20
16
let repo = GitRepository::discover(Path::new("."))?;
21
17
let mut status_opts = StatusOptions::new();
+341
-65
crates/tangled-cli/src/commands/pr.rs
+341
-65
crates/tangled-cli/src/commands/pr.rs
···
2
2
use anyhow::{anyhow, Result};
3
3
use std::path::Path;
4
4
use std::process::Command;
5
-
use tangled_config::session::SessionManager;
6
5
7
6
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
8
7
match cmd {
···
15
14
}
16
15
17
16
async fn list(args: PrListArgs) -> Result<()> {
18
-
let mgr = SessionManager::default();
19
-
let session = mgr
20
-
.load()?
21
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
17
+
let session = crate::util::load_session_with_refresh().await?;
22
18
let pds = session
23
19
.pds
24
20
.clone()
···
54
50
55
51
async fn create(args: PrCreateArgs) -> Result<()> {
56
52
// Must be run inside the repo checkout; we will use git format-patch to build the patch
57
-
let mgr = SessionManager::default();
58
-
let session = mgr
59
-
.load()?
60
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
53
+
let session = crate::util::load_session_with_refresh().await?;
61
54
let pds = session
62
55
.pds
63
56
.clone()
···
126
119
}
127
120
128
121
async fn show(args: PrShowArgs) -> Result<()> {
129
-
let mgr = SessionManager::default();
130
-
let session = mgr
131
-
.load()?
132
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
122
+
let session = crate::util::load_session_with_refresh().await?;
133
123
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
134
124
let pds = session
135
125
.pds
···
152
142
}
153
143
154
144
async fn review(args: PrReviewArgs) -> Result<()> {
155
-
let mgr = SessionManager::default();
156
-
let session = mgr
157
-
.load()?
158
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
145
+
let session = crate::util::load_session_with_refresh().await?;
159
146
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
160
147
let pds = session
161
148
.pds
···
184
171
}
185
172
186
173
async fn merge(args: PrMergeArgs) -> Result<()> {
187
-
let mgr = SessionManager::default();
188
-
let session = mgr
189
-
.load()?
190
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
174
+
let session = crate::util::load_session_with_refresh().await?;
191
175
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
192
176
let pds = session
193
177
.pds
···
195
179
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
196
180
.unwrap_or_else(|| "https://bsky.social".into());
197
181
198
-
// Get the PR to find the target repo
182
+
// Get the PR
199
183
let pds_client = tangled_api::TangledClient::new(&pds);
200
184
let pull = pds_client
201
185
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
202
186
.await?;
203
187
204
-
// Parse the target repo AT-URI to get did and name
205
-
let target_repo = &pull.target.repo;
206
-
// Format: at://did:plc:.../sh.tangled.repo/rkey
207
-
let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect();
208
-
if parts.len() < 2 {
209
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
210
-
}
211
-
let repo_did = parts[0];
188
+
// Parse target repo info
189
+
let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?;
212
190
213
-
// Get repo info to find the name
214
-
// Parse rkey from target repo AT-URI
215
-
let repo_rkey = if parts.len() >= 4 {
216
-
parts[3]
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?;
217
205
} else {
218
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
219
-
};
220
-
221
-
#[derive(serde::Deserialize)]
222
-
struct Rec {
223
-
name: String,
224
-
}
225
-
#[derive(serde::Deserialize)]
226
-
struct GetRes {
227
-
value: Rec,
206
+
// Single PR merge (existing logic)
207
+
merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?;
228
208
}
229
-
let params = [
230
-
("repo", repo_did.to_string()),
231
-
("collection", "sh.tangled.repo".to_string()),
232
-
("rkey", repo_rkey.to_string()),
233
-
];
234
-
let repo_rec: GetRes = pds_client
235
-
.get_json("com.atproto.repo.getRecord", ¶ms, Some(session.access_jwt.as_str()))
236
-
.await?;
237
209
238
-
// Call merge on the default Tangled API base (tngl.sh)
239
-
let api = tangled_api::TangledClient::default();
240
-
api.merge_pull(
241
-
&did,
242
-
&rkey,
243
-
repo_did,
244
-
&repo_rec.value.name,
245
-
&pds,
246
-
&session.access_jwt,
247
-
)
248
-
.await?;
249
-
250
-
println!("Merged PR {}:{}", did, rkey);
251
210
Ok(())
252
211
}
253
212
···
275
234
}
276
235
Ok((default_did.to_string(), id.to_string()))
277
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
+
278
+
println!(
279
+
"โ Detected PR is part of stack (stack has {} total PRs)",
280
+
stack.len()
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
+
¤t_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
+
350
+
Ok(())
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(¤t_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
+
¶ms,
548
+
Some(&session.access_jwt),
549
+
)
550
+
.await?;
551
+
552
+
Ok((repo_did, repo_rec.value.name))
553
+
}
+7
-31
crates/tangled-cli/src/commands/repo.rs
+7
-31
crates/tangled-cli/src/commands/repo.rs
···
2
2
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
3
3
use serde_json;
4
4
use std::path::PathBuf;
5
-
use tangled_config::session::SessionManager;
6
5
7
6
use crate::cli::{
8
7
Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs,
···
22
21
}
23
22
24
23
async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> {
25
-
let mgr = SessionManager::default();
26
-
let session = match mgr.load()? {
27
-
Some(s) => s,
28
-
None => return Err(anyhow!("Please login first: tangled auth login")),
29
-
};
24
+
let session = crate::util::load_session_with_refresh().await?;
30
25
31
26
// Use the PDS to list repo records for the user
32
27
let pds = session
···
63
58
}
64
59
65
60
async fn create(args: RepoCreateArgs) -> Result<()> {
66
-
let mgr = SessionManager::default();
67
-
let session = match mgr.load()? {
68
-
Some(s) => s,
69
-
None => return Err(anyhow!("Please login first: tangled auth login")),
70
-
};
61
+
let session = crate::util::load_session_with_refresh().await?;
71
62
72
63
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into());
73
64
let client = tangled_api::TangledClient::new(base);
···
97
88
}
98
89
99
90
async fn clone(args: RepoCloneArgs) -> Result<()> {
100
-
let mgr = SessionManager::default();
101
-
let session = mgr
102
-
.load()?
103
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
91
+
let session = crate::util::load_session_with_refresh().await?;
104
92
105
93
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
106
94
let pds = session
···
164
152
}
165
153
166
154
async fn info(args: RepoInfoArgs) -> Result<()> {
167
-
let mgr = SessionManager::default();
168
-
let session = mgr
169
-
.load()?
170
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
155
+
let session = crate::util::load_session_with_refresh().await?;
171
156
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
172
157
let pds = session
173
158
.pds
···
235
220
}
236
221
237
222
async fn delete(args: RepoDeleteArgs) -> Result<()> {
238
-
let mgr = SessionManager::default();
239
-
let session = mgr
240
-
.load()?
241
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
223
+
let session = crate::util::load_session_with_refresh().await?;
242
224
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
243
225
let pds = session
244
226
.pds
···
258
240
}
259
241
260
242
async fn star(args: RepoRefArgs) -> Result<()> {
261
-
let mgr = SessionManager::default();
262
-
let session = mgr
263
-
.load()?
264
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
243
+
let session = crate::util::load_session_with_refresh().await?;
265
244
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
266
245
let pds = session
267
246
.pds
···
281
260
}
282
261
283
262
async fn unstar(args: RepoRefArgs) -> Result<()> {
284
-
let mgr = SessionManager::default();
285
-
let session = mgr
286
-
.load()?
287
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
263
+
let session = crate::util::load_session_with_refresh().await?;
288
264
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
289
265
let pds = session
290
266
.pds
+32
-26
crates/tangled-cli/src/commands/spindle.rs
+32
-26
crates/tangled-cli/src/commands/spindle.rs
···
4
4
};
5
5
use anyhow::{anyhow, Result};
6
6
use futures_util::StreamExt;
7
-
use tangled_config::session::SessionManager;
8
7
use tokio_tungstenite::{connect_async, tungstenite::Message};
9
8
10
9
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
···
18
17
}
19
18
20
19
async fn list(args: SpindleListArgs) -> Result<()> {
21
-
let mgr = SessionManager::default();
22
-
let session = mgr
23
-
.load()?
24
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
20
+
let session = crate::util::load_session_with_refresh().await?;
25
21
26
22
let pds = session
27
23
.pds
···
65
61
}
66
62
67
63
async fn config(args: SpindleConfigArgs) -> Result<()> {
68
-
let mgr = SessionManager::default();
69
-
let session = mgr
70
-
.load()?
71
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
64
+
let session = crate::util::load_session_with_refresh().await?;
72
65
73
66
if args.enable && args.disable {
74
67
return Err(anyhow!("Cannot use --enable and --disable together"));
···
138
131
(parts[0].to_string(), parts[1].to_string(), parts[2].to_string())
139
132
} else if parts.len() == 1 {
140
133
// Use repo context - need to get repo info
141
-
let mgr = SessionManager::default();
142
-
let session = mgr
143
-
.load()?
144
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
134
+
let session = crate::util::load_session_with_refresh().await?;
145
135
let pds = session
146
136
.pds
147
137
.clone()
···
205
195
}
206
196
207
197
async fn secret_list(args: SpindleSecretListArgs) -> Result<()> {
208
-
let mgr = SessionManager::default();
209
-
let session = mgr
210
-
.load()?
211
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
198
+
let session = crate::util::load_session_with_refresh().await?;
212
199
let pds = session
213
200
.pds
214
201
.clone()
···
243
230
}
244
231
245
232
async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
246
-
let mgr = SessionManager::default();
247
-
let session = mgr
248
-
.load()?
249
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
233
+
let session = crate::util::load_session_with_refresh().await?;
250
234
let pds = session
251
235
.pds
252
236
.clone()
···
266
250
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
267
251
let api = tangled_api::TangledClient::new(&spindle_base);
268
252
269
-
api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value)
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)
270
279
.await?;
271
280
println!("Added secret '{}' to {}", args.key, args.repo);
272
281
Ok(())
273
282
}
274
283
275
284
async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
276
-
let mgr = SessionManager::default();
277
-
let session = mgr
278
-
.load()?
279
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
285
+
let session = crate::util::load_session_with_refresh().await?;
280
286
let pds = session
281
287
.pds
282
288
.clone()
+1
crates/tangled-cli/src/main.rs
+1
crates/tangled-cli/src/main.rs
+55
crates/tangled-cli/src/util.rs
+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
+
}