+546
crates/tangled-api/src/client.rs
+546
crates/tangled-api/src/client.rs
···
629
629
None
630
630
}
631
631
}
632
+
633
+
// ========== Issues ==========
634
+
pub async fn list_issues(
635
+
&self,
636
+
author_did: &str,
637
+
repo_at_uri: Option<&str>,
638
+
bearer: Option<&str>,
639
+
) -> Result<Vec<IssueRecord>> {
640
+
#[derive(Deserialize)]
641
+
struct Item {
642
+
uri: String,
643
+
value: Issue,
644
+
}
645
+
#[derive(Deserialize)]
646
+
struct ListRes {
647
+
#[serde(default)]
648
+
records: Vec<Item>,
649
+
}
650
+
let params = vec![
651
+
("repo", author_did.to_string()),
652
+
("collection", "sh.tangled.repo.issue".to_string()),
653
+
("limit", "100".to_string()),
654
+
];
655
+
let res: ListRes = self
656
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
657
+
.await?;
658
+
let mut out = vec![];
659
+
for it in res.records {
660
+
if let Some(filter_repo) = repo_at_uri {
661
+
if it.value.repo.as_str() != filter_repo {
662
+
continue;
663
+
}
664
+
}
665
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
666
+
out.push(IssueRecord {
667
+
author_did: author_did.to_string(),
668
+
rkey,
669
+
issue: it.value,
670
+
});
671
+
}
672
+
Ok(out)
673
+
}
674
+
675
+
#[allow(clippy::too_many_arguments)]
676
+
pub async fn create_issue(
677
+
&self,
678
+
author_did: &str,
679
+
repo_did: &str,
680
+
repo_rkey: &str,
681
+
title: &str,
682
+
body: Option<&str>,
683
+
pds_base: &str,
684
+
access_jwt: &str,
685
+
) -> Result<String> {
686
+
#[derive(Serialize)]
687
+
struct Rec<'a> {
688
+
repo: &'a str,
689
+
title: &'a str,
690
+
#[serde(skip_serializing_if = "Option::is_none")]
691
+
body: Option<&'a str>,
692
+
#[serde(rename = "createdAt")]
693
+
created_at: String,
694
+
}
695
+
#[derive(Serialize)]
696
+
struct Req<'a> {
697
+
repo: &'a str,
698
+
collection: &'a str,
699
+
validate: bool,
700
+
record: Rec<'a>,
701
+
}
702
+
#[derive(Deserialize)]
703
+
struct Res {
704
+
uri: String,
705
+
}
706
+
let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
707
+
let now = chrono::Utc::now().to_rfc3339();
708
+
let rec = Rec {
709
+
repo: &issue_repo_at,
710
+
title,
711
+
body,
712
+
created_at: now,
713
+
};
714
+
let req = Req {
715
+
repo: author_did,
716
+
collection: "sh.tangled.repo.issue",
717
+
validate: true,
718
+
record: rec,
719
+
};
720
+
let pds_client = TangledClient::new(pds_base);
721
+
let res: Res = pds_client
722
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
723
+
.await?;
724
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri"))
725
+
}
726
+
727
+
pub async fn comment_issue(
728
+
&self,
729
+
author_did: &str,
730
+
issue_at: &str,
731
+
body: &str,
732
+
pds_base: &str,
733
+
access_jwt: &str,
734
+
) -> Result<String> {
735
+
#[derive(Serialize)]
736
+
struct Rec<'a> {
737
+
issue: &'a str,
738
+
body: &'a str,
739
+
#[serde(rename = "createdAt")]
740
+
created_at: String,
741
+
}
742
+
#[derive(Serialize)]
743
+
struct Req<'a> {
744
+
repo: &'a str,
745
+
collection: &'a str,
746
+
validate: bool,
747
+
record: Rec<'a>,
748
+
}
749
+
#[derive(Deserialize)]
750
+
struct Res {
751
+
uri: String,
752
+
}
753
+
let now = chrono::Utc::now().to_rfc3339();
754
+
let rec = Rec {
755
+
issue: issue_at,
756
+
body,
757
+
created_at: now,
758
+
};
759
+
let req = Req {
760
+
repo: author_did,
761
+
collection: "sh.tangled.repo.issue.comment",
762
+
validate: true,
763
+
record: rec,
764
+
};
765
+
let pds_client = TangledClient::new(pds_base);
766
+
let res: Res = pds_client
767
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
768
+
.await?;
769
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri"))
770
+
}
771
+
772
+
pub async fn get_issue_record(
773
+
&self,
774
+
author_did: &str,
775
+
rkey: &str,
776
+
bearer: Option<&str>,
777
+
) -> Result<Issue> {
778
+
#[derive(Deserialize)]
779
+
struct GetRes {
780
+
value: Issue,
781
+
}
782
+
let params = [
783
+
("repo", author_did.to_string()),
784
+
("collection", "sh.tangled.repo.issue".to_string()),
785
+
("rkey", rkey.to_string()),
786
+
];
787
+
let res: GetRes = self
788
+
.get_json("com.atproto.repo.getRecord", ¶ms, bearer)
789
+
.await?;
790
+
Ok(res.value)
791
+
}
792
+
793
+
pub async fn put_issue_record(
794
+
&self,
795
+
author_did: &str,
796
+
rkey: &str,
797
+
record: &Issue,
798
+
bearer: Option<&str>,
799
+
) -> Result<()> {
800
+
#[derive(Serialize)]
801
+
struct PutReq<'a> {
802
+
repo: &'a str,
803
+
collection: &'a str,
804
+
rkey: &'a str,
805
+
validate: bool,
806
+
record: &'a Issue,
807
+
}
808
+
let req = PutReq {
809
+
repo: author_did,
810
+
collection: "sh.tangled.repo.issue",
811
+
rkey,
812
+
validate: true,
813
+
record,
814
+
};
815
+
let _: serde_json::Value = self
816
+
.post_json("com.atproto.repo.putRecord", &req, bearer)
817
+
.await?;
818
+
Ok(())
819
+
}
820
+
821
+
pub async fn set_issue_state(
822
+
&self,
823
+
author_did: &str,
824
+
issue_at: &str,
825
+
state_nsid: &str,
826
+
pds_base: &str,
827
+
access_jwt: &str,
828
+
) -> Result<String> {
829
+
#[derive(Serialize)]
830
+
struct Rec<'a> {
831
+
issue: &'a str,
832
+
state: &'a str,
833
+
}
834
+
#[derive(Serialize)]
835
+
struct Req<'a> {
836
+
repo: &'a str,
837
+
collection: &'a str,
838
+
validate: bool,
839
+
record: Rec<'a>,
840
+
}
841
+
#[derive(Deserialize)]
842
+
struct Res {
843
+
uri: String,
844
+
}
845
+
let rec = Rec {
846
+
issue: issue_at,
847
+
state: state_nsid,
848
+
};
849
+
let req = Req {
850
+
repo: author_did,
851
+
collection: "sh.tangled.repo.issue.state",
852
+
validate: true,
853
+
record: rec,
854
+
};
855
+
let pds_client = TangledClient::new(pds_base);
856
+
let res: Res = pds_client
857
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
858
+
.await?;
859
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri"))
860
+
}
861
+
862
+
pub async fn get_pull_record(
863
+
&self,
864
+
author_did: &str,
865
+
rkey: &str,
866
+
bearer: Option<&str>,
867
+
) -> Result<Pull> {
868
+
#[derive(Deserialize)]
869
+
struct GetRes {
870
+
value: Pull,
871
+
}
872
+
let params = [
873
+
("repo", author_did.to_string()),
874
+
("collection", "sh.tangled.repo.pull".to_string()),
875
+
("rkey", rkey.to_string()),
876
+
];
877
+
let res: GetRes = self
878
+
.get_json("com.atproto.repo.getRecord", ¶ms, bearer)
879
+
.await?;
880
+
Ok(res.value)
881
+
}
882
+
883
+
// ========== Pull Requests ==========
884
+
pub async fn list_pulls(
885
+
&self,
886
+
author_did: &str,
887
+
target_repo_at_uri: Option<&str>,
888
+
bearer: Option<&str>,
889
+
) -> Result<Vec<PullRecord>> {
890
+
#[derive(Deserialize)]
891
+
struct Item {
892
+
uri: String,
893
+
value: Pull,
894
+
}
895
+
#[derive(Deserialize)]
896
+
struct ListRes {
897
+
#[serde(default)]
898
+
records: Vec<Item>,
899
+
}
900
+
let params = vec![
901
+
("repo", author_did.to_string()),
902
+
("collection", "sh.tangled.repo.pull".to_string()),
903
+
("limit", "100".to_string()),
904
+
];
905
+
let res: ListRes = self
906
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
907
+
.await?;
908
+
let mut out = vec![];
909
+
for it in res.records {
910
+
if let Some(target) = target_repo_at_uri {
911
+
if it.value.target.repo.as_str() != target {
912
+
continue;
913
+
}
914
+
}
915
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
916
+
out.push(PullRecord {
917
+
author_did: author_did.to_string(),
918
+
rkey,
919
+
pull: it.value,
920
+
});
921
+
}
922
+
Ok(out)
923
+
}
924
+
925
+
#[allow(clippy::too_many_arguments)]
926
+
pub async fn create_pull(
927
+
&self,
928
+
author_did: &str,
929
+
repo_did: &str,
930
+
repo_rkey: &str,
931
+
target_branch: &str,
932
+
patch: &str,
933
+
title: &str,
934
+
body: Option<&str>,
935
+
pds_base: &str,
936
+
access_jwt: &str,
937
+
) -> Result<String> {
938
+
#[derive(Serialize)]
939
+
struct Target<'a> {
940
+
repo: &'a str,
941
+
branch: &'a str,
942
+
}
943
+
#[derive(Serialize)]
944
+
struct Rec<'a> {
945
+
target: Target<'a>,
946
+
title: &'a str,
947
+
#[serde(skip_serializing_if = "Option::is_none")]
948
+
body: Option<&'a str>,
949
+
patch: &'a str,
950
+
#[serde(rename = "createdAt")]
951
+
created_at: String,
952
+
}
953
+
#[derive(Serialize)]
954
+
struct Req<'a> {
955
+
repo: &'a str,
956
+
collection: &'a str,
957
+
validate: bool,
958
+
record: Rec<'a>,
959
+
}
960
+
#[derive(Deserialize)]
961
+
struct Res {
962
+
uri: String,
963
+
}
964
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
965
+
let now = chrono::Utc::now().to_rfc3339();
966
+
let rec = Rec {
967
+
target: Target {
968
+
repo: &repo_at,
969
+
branch: target_branch,
970
+
},
971
+
title,
972
+
body,
973
+
patch,
974
+
created_at: now,
975
+
};
976
+
let req = Req {
977
+
repo: author_did,
978
+
collection: "sh.tangled.repo.pull",
979
+
validate: true,
980
+
record: rec,
981
+
};
982
+
let pds_client = TangledClient::new(pds_base);
983
+
let res: Res = pds_client
984
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
985
+
.await?;
986
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri"))
987
+
}
988
+
989
+
// ========== Spindle: Secrets Management ==========
990
+
pub async fn list_repo_secrets(
991
+
&self,
992
+
pds_base: &str,
993
+
access_jwt: &str,
994
+
repo_at: &str,
995
+
) -> Result<Vec<Secret>> {
996
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
997
+
#[derive(Deserialize)]
998
+
struct Res {
999
+
secrets: Vec<Secret>,
1000
+
}
1001
+
let params = [("repo", repo_at.to_string())];
1002
+
let res: Res = self
1003
+
.get_json("sh.tangled.repo.listSecrets", ¶ms, Some(&sa))
1004
+
.await?;
1005
+
Ok(res.secrets)
1006
+
}
1007
+
1008
+
pub async fn add_repo_secret(
1009
+
&self,
1010
+
pds_base: &str,
1011
+
access_jwt: &str,
1012
+
repo_at: &str,
1013
+
key: &str,
1014
+
value: &str,
1015
+
) -> Result<()> {
1016
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1017
+
#[derive(Serialize)]
1018
+
struct Req<'a> {
1019
+
repo: &'a str,
1020
+
key: &'a str,
1021
+
value: &'a str,
1022
+
}
1023
+
let body = Req {
1024
+
repo: repo_at,
1025
+
key,
1026
+
value,
1027
+
};
1028
+
let _: serde_json::Value = self
1029
+
.post_json("sh.tangled.repo.addSecret", &body, Some(&sa))
1030
+
.await?;
1031
+
Ok(())
1032
+
}
1033
+
1034
+
pub async fn remove_repo_secret(
1035
+
&self,
1036
+
pds_base: &str,
1037
+
access_jwt: &str,
1038
+
repo_at: &str,
1039
+
key: &str,
1040
+
) -> Result<()> {
1041
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1042
+
#[derive(Serialize)]
1043
+
struct Req<'a> {
1044
+
repo: &'a str,
1045
+
key: &'a str,
1046
+
}
1047
+
let body = Req { repo: repo_at, key };
1048
+
let _: serde_json::Value = self
1049
+
.post_json("sh.tangled.repo.removeSecret", &body, Some(&sa))
1050
+
.await?;
1051
+
Ok(())
1052
+
}
1053
+
1054
+
async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> {
1055
+
let host = self
1056
+
.base_url
1057
+
.trim_end_matches('/')
1058
+
.strip_prefix("https://")
1059
+
.or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
1060
+
.ok_or_else(|| anyhow!("invalid base_url"))?;
1061
+
let audience = format!("did:web:{}", host);
1062
+
#[derive(Deserialize)]
1063
+
struct GetSARes {
1064
+
token: String,
1065
+
}
1066
+
let pds = TangledClient::new(pds_base);
1067
+
let params = [
1068
+
("aud", audience),
1069
+
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
1070
+
];
1071
+
let sa: GetSARes = pds
1072
+
.get_json(
1073
+
"com.atproto.server.getServiceAuth",
1074
+
¶ms,
1075
+
Some(access_jwt),
1076
+
)
1077
+
.await?;
1078
+
Ok(sa.token)
1079
+
}
1080
+
1081
+
pub async fn comment_pull(
1082
+
&self,
1083
+
author_did: &str,
1084
+
pull_at: &str,
1085
+
body: &str,
1086
+
pds_base: &str,
1087
+
access_jwt: &str,
1088
+
) -> Result<String> {
1089
+
#[derive(Serialize)]
1090
+
struct Rec<'a> {
1091
+
pull: &'a str,
1092
+
body: &'a str,
1093
+
#[serde(rename = "createdAt")]
1094
+
created_at: String,
1095
+
}
1096
+
#[derive(Serialize)]
1097
+
struct Req<'a> {
1098
+
repo: &'a str,
1099
+
collection: &'a str,
1100
+
validate: bool,
1101
+
record: Rec<'a>,
1102
+
}
1103
+
#[derive(Deserialize)]
1104
+
struct Res {
1105
+
uri: String,
1106
+
}
1107
+
let now = chrono::Utc::now().to_rfc3339();
1108
+
let rec = Rec {
1109
+
pull: pull_at,
1110
+
body,
1111
+
created_at: now,
1112
+
};
1113
+
let req = Req {
1114
+
repo: author_did,
1115
+
collection: "sh.tangled.repo.pull.comment",
1116
+
validate: true,
1117
+
record: rec,
1118
+
};
1119
+
let pds_client = TangledClient::new(pds_base);
1120
+
let res: Res = pds_client
1121
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
1122
+
.await?;
1123
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri"))
1124
+
}
632
1125
}
633
1126
634
1127
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
642
1135
pub private: bool,
643
1136
}
644
1137
1138
+
// Issue record value
1139
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1140
+
pub struct Issue {
1141
+
pub repo: String,
1142
+
pub title: String,
1143
+
#[serde(default)]
1144
+
pub body: String,
1145
+
#[serde(rename = "createdAt")]
1146
+
pub created_at: String,
1147
+
}
1148
+
1149
+
#[derive(Debug, Clone)]
1150
+
pub struct IssueRecord {
1151
+
pub author_did: String,
1152
+
pub rkey: String,
1153
+
pub issue: Issue,
1154
+
}
1155
+
1156
+
// Pull record value (subset)
1157
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1158
+
pub struct PullTarget {
1159
+
pub repo: String,
1160
+
pub branch: String,
1161
+
}
1162
+
1163
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1164
+
pub struct Pull {
1165
+
pub target: PullTarget,
1166
+
pub title: String,
1167
+
#[serde(default)]
1168
+
pub body: String,
1169
+
pub patch: String,
1170
+
#[serde(rename = "createdAt")]
1171
+
pub created_at: String,
1172
+
}
1173
+
1174
+
#[derive(Debug, Clone)]
1175
+
pub struct PullRecord {
1176
+
pub author_did: String,
1177
+
pub rkey: String,
1178
+
pub pull: Pull,
1179
+
}
1180
+
645
1181
#[derive(Debug, Clone)]
646
1182
pub struct RepoRecord {
647
1183
pub did: String,
···
683
1219
pub subject: String,
684
1220
#[serde(rename = "createdAt")]
685
1221
pub created_at: String,
1222
+
}
1223
+
1224
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1225
+
pub struct Secret {
1226
+
pub repo: String,
1227
+
pub key: String,
1228
+
#[serde(rename = "createdAt")]
1229
+
pub created_at: String,
1230
+
#[serde(rename = "createdBy")]
1231
+
pub created_by: String,
686
1232
}
687
1233
688
1234
#[derive(Debug, Clone)]
+4
crates/tangled-api/src/lib.rs
+4
crates/tangled-api/src/lib.rs
+43
crates/tangled-cli/src/cli.rs
+43
crates/tangled-cli/src/cli.rs
···
354
354
Config(SpindleConfigArgs),
355
355
Run(SpindleRunArgs),
356
356
Logs(SpindleLogsArgs),
357
+
/// Secrets management
358
+
#[command(subcommand)]
359
+
Secret(SpindleSecretCommand),
357
360
}
358
361
359
362
#[derive(Args, Debug, Clone)]
···
392
395
#[arg(long)]
393
396
pub lines: Option<usize>,
394
397
}
398
+
399
+
#[derive(Subcommand, Debug, Clone)]
400
+
pub enum SpindleSecretCommand {
401
+
/// List secrets for a repo
402
+
List(SpindleSecretListArgs),
403
+
/// Add or update a secret
404
+
Add(SpindleSecretAddArgs),
405
+
/// Remove a secret
406
+
Remove(SpindleSecretRemoveArgs),
407
+
}
408
+
409
+
#[derive(Args, Debug, Clone)]
410
+
pub struct SpindleSecretListArgs {
411
+
/// Repo: <owner>/<name>
412
+
#[arg(long)]
413
+
pub repo: String,
414
+
}
415
+
416
+
#[derive(Args, Debug, Clone)]
417
+
pub struct SpindleSecretAddArgs {
418
+
/// Repo: <owner>/<name>
419
+
#[arg(long)]
420
+
pub repo: String,
421
+
/// Secret key
422
+
#[arg(long)]
423
+
pub key: String,
424
+
/// Secret value
425
+
#[arg(long)]
426
+
pub value: String,
427
+
}
428
+
429
+
#[derive(Args, Debug, Clone)]
430
+
pub struct SpindleSecretRemoveArgs {
431
+
/// Repo: <owner>/<name>
432
+
#[arg(long)]
433
+
pub repo: String,
434
+
/// Secret key
435
+
#[arg(long)]
436
+
pub key: String,
437
+
}
+208
-21
crates/tangled-cli/src/commands/issue.rs
+208
-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;
7
+
use tangled_config::session::SessionManager;
6
8
7
9
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
8
10
match cmd {
···
15
17
}
16
18
17
19
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
-
);
20
+
let mgr = SessionManager::default();
21
+
let session = mgr
22
+
.load()?
23
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
24
+
let pds = session
25
+
.pds
26
+
.clone()
27
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
28
+
.unwrap_or_else(|| "https://bsky.social".into());
29
+
let client = tangled_api::TangledClient::new(&pds);
30
+
31
+
let repo_filter_at = if let Some(repo) = &args.repo {
32
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
33
+
let info = client
34
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
35
+
.await?;
36
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
37
+
} else {
38
+
None
39
+
};
40
+
41
+
let items = client
42
+
.list_issues(
43
+
&session.did,
44
+
repo_filter_at.as_deref(),
45
+
Some(session.access_jwt.as_str()),
46
+
)
47
+
.await?;
48
+
if items.is_empty() {
49
+
println!("No issues found (showing only issues you created)");
50
+
} else {
51
+
println!("RKEY\tTITLE\tREPO");
52
+
for it in items {
53
+
println!("{}\t{}\t{}", it.rkey, it.issue.title, it.issue.repo);
54
+
}
55
+
}
22
56
Ok(())
23
57
}
24
58
25
59
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
-
);
60
+
let mgr = SessionManager::default();
61
+
let session = mgr
62
+
.load()?
63
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
64
+
let pds = session
65
+
.pds
66
+
.clone()
67
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
68
+
.unwrap_or_else(|| "https://bsky.social".into());
69
+
let client = tangled_api::TangledClient::new(&pds);
70
+
71
+
let repo = args
72
+
.repo
73
+
.as_ref()
74
+
.ok_or_else(|| anyhow!("--repo is required for issue create"))?;
75
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
76
+
let info = client
77
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
78
+
.await?;
79
+
let title = args
80
+
.title
81
+
.as_deref()
82
+
.ok_or_else(|| anyhow!("--title is required for issue create"))?;
83
+
let rkey = client
84
+
.create_issue(
85
+
&session.did,
86
+
&info.did,
87
+
&info.rkey,
88
+
title,
89
+
args.body.as_deref(),
90
+
&pds,
91
+
&session.access_jwt,
92
+
)
93
+
.await?;
94
+
println!("Created issue rkey={} in {}/{}", rkey, owner, name);
30
95
Ok(())
31
96
}
32
97
33
98
async fn show(args: IssueShowArgs) -> Result<()> {
34
-
println!(
35
-
"Issue show (stub) id={} comments={} json={}",
36
-
args.id, args.comments, args.json
37
-
);
99
+
// 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"))?;
104
+
let id = args.id;
105
+
let (did, rkey) = parse_record_id(&id, &session.did)?;
106
+
let pds = session
107
+
.pds
108
+
.clone()
109
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
110
+
.unwrap_or_else(|| "https://bsky.social".into());
111
+
let client = tangled_api::TangledClient::new(&pds);
112
+
// Fetch all issues by this DID and find rkey
113
+
let items = client
114
+
.list_issues(&did, None, Some(session.access_jwt.as_str()))
115
+
.await?;
116
+
if let Some(it) = items.into_iter().find(|i| i.rkey == rkey) {
117
+
println!("TITLE: {}", it.issue.title);
118
+
if !it.issue.body.is_empty() {
119
+
println!("BODY:\n{}", it.issue.body);
120
+
}
121
+
println!("REPO: {}", it.issue.repo);
122
+
println!("AUTHOR: {}", it.author_did);
123
+
println!("RKEY: {}", rkey);
124
+
} else {
125
+
println!("Issue not found for did={} rkey={}", did, rkey);
126
+
}
38
127
Ok(())
39
128
}
40
129
41
130
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
-
);
131
+
// 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"))?;
136
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
137
+
let pds = session
138
+
.pds
139
+
.clone()
140
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
141
+
.unwrap_or_else(|| "https://bsky.social".into());
142
+
// Get existing
143
+
let client = tangled_api::TangledClient::new(&pds);
144
+
let mut rec: Issue = client
145
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
146
+
.await?;
147
+
if let Some(t) = args.title.as_deref() {
148
+
rec.title = t.to_string();
149
+
}
150
+
if let Some(b) = args.body.as_deref() {
151
+
rec.body = b.to_string();
152
+
}
153
+
// Put record back
154
+
client
155
+
.put_issue_record(&did, &rkey, &rec, Some(session.access_jwt.as_str()))
156
+
.await?;
157
+
158
+
// Optional state change
159
+
if let Some(state) = args.state.as_deref() {
160
+
let state_nsid = match state {
161
+
"open" => "sh.tangled.repo.issue.state.open",
162
+
"closed" => "sh.tangled.repo.issue.state.closed",
163
+
other => {
164
+
return Err(anyhow!(format!(
165
+
"unknown state '{}', expected 'open' or 'closed'",
166
+
other
167
+
)))
168
+
}
169
+
};
170
+
let issue_at = rec.repo.clone();
171
+
client
172
+
.set_issue_state(
173
+
&session.did,
174
+
&issue_at,
175
+
state_nsid,
176
+
&pds,
177
+
&session.access_jwt,
178
+
)
179
+
.await?;
180
+
}
181
+
println!("Updated issue {}:{}", did, rkey);
46
182
Ok(())
47
183
}
48
184
49
185
async fn comment(args: IssueCommentArgs) -> Result<()> {
50
-
println!(
51
-
"Issue comment (stub) id={} close={} body={:?}",
52
-
args.id, args.close, args.body
53
-
);
186
+
let mgr = SessionManager::default();
187
+
let session = mgr
188
+
.load()?
189
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
190
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
191
+
let pds = session
192
+
.pds
193
+
.clone()
194
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
195
+
.unwrap_or_else(|| "https://bsky.social".into());
196
+
// Resolve issue AT-URI
197
+
let client = tangled_api::TangledClient::new(&pds);
198
+
let issue_at = client
199
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
200
+
.await?
201
+
.repo;
202
+
if let Some(body) = args.body.as_deref() {
203
+
client
204
+
.comment_issue(&session.did, &issue_at, body, &pds, &session.access_jwt)
205
+
.await?;
206
+
println!("Comment posted");
207
+
}
208
+
if args.close {
209
+
client
210
+
.set_issue_state(
211
+
&session.did,
212
+
&issue_at,
213
+
"sh.tangled.repo.issue.state.closed",
214
+
&pds,
215
+
&session.access_jwt,
216
+
)
217
+
.await?;
218
+
println!("Issue closed");
219
+
}
54
220
Ok(())
55
221
}
222
+
223
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
224
+
if let Some((owner, name)) = spec.split_once('/') {
225
+
(owner, name)
226
+
} else {
227
+
(default_owner, spec)
228
+
}
229
+
}
230
+
231
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
232
+
if let Some(rest) = id.strip_prefix("at://") {
233
+
let parts: Vec<&str> = rest.split('/').collect();
234
+
if parts.len() >= 4 {
235
+
return Ok((parts[0].to_string(), parts[3].to_string()));
236
+
}
237
+
}
238
+
if let Some((did, rkey)) = id.split_once(':') {
239
+
return Ok((did.to_string(), rkey.to_string()));
240
+
}
241
+
Ok((default_did.to_string(), id.to_string()))
242
+
}
+183
-20
crates/tangled-cli/src/commands/pr.rs
+183
-20
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;
5
+
use tangled_config::session::SessionManager;
3
6
4
7
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
5
8
match cmd {
···
12
15
}
13
16
14
17
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
-
);
18
+
let mgr = SessionManager::default();
19
+
let session = mgr
20
+
.load()?
21
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
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 client = tangled_api::TangledClient::new(&pds);
28
+
let target_repo_at = if let Some(repo) = &args.repo {
29
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
30
+
let info = client
31
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
32
+
.await?;
33
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
34
+
} else {
35
+
None
36
+
};
37
+
let pulls = client
38
+
.list_pulls(
39
+
&session.did,
40
+
target_repo_at.as_deref(),
41
+
Some(session.access_jwt.as_str()),
42
+
)
43
+
.await?;
44
+
if pulls.is_empty() {
45
+
println!("No pull requests found (showing only those you created)");
46
+
} else {
47
+
println!("RKEY\tTITLE\tTARGET");
48
+
for pr in pulls {
49
+
println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo);
50
+
}
51
+
}
19
52
Ok(())
20
53
}
21
54
22
55
async fn create(args: PrCreateArgs) -> Result<()> {
56
+
// 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"))?;
61
+
let pds = session
62
+
.pds
63
+
.clone()
64
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
65
+
.unwrap_or_else(|| "https://bsky.social".into());
66
+
let client = tangled_api::TangledClient::new(&pds);
67
+
68
+
let repo = args
69
+
.repo
70
+
.as_ref()
71
+
.ok_or_else(|| anyhow!("--repo is required for pr create"))?;
72
+
let (owner, name) = parse_repo_ref(repo, "");
73
+
let info = client
74
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
75
+
.await?;
76
+
77
+
let base = args
78
+
.base
79
+
.as_deref()
80
+
.ok_or_else(|| anyhow!("--base is required (target branch)"))?;
81
+
let head = args
82
+
.head
83
+
.as_deref()
84
+
.ok_or_else(|| anyhow!("--head is required (source range/branch)"))?;
85
+
86
+
// Generate format-patch using external git for fidelity
87
+
let output = Command::new("git")
88
+
.arg("format-patch")
89
+
.arg("--stdout")
90
+
.arg(format!("{}..{}", base, head))
91
+
.current_dir(Path::new("."))
92
+
.output()?;
93
+
if !output.status.success() {
94
+
return Err(anyhow!("failed to run git format-patch"));
95
+
}
96
+
let patch = String::from_utf8_lossy(&output.stdout).to_string();
97
+
if patch.trim().is_empty() {
98
+
return Err(anyhow!("no changes between base and head"));
99
+
}
100
+
101
+
let title_buf;
102
+
let title = if let Some(t) = args.title.as_deref() {
103
+
t
104
+
} else {
105
+
title_buf = format!("{} -> {}", head, base);
106
+
&title_buf
107
+
};
108
+
let rkey = client
109
+
.create_pull(
110
+
&session.did,
111
+
&info.did,
112
+
&info.rkey,
113
+
base,
114
+
&patch,
115
+
title,
116
+
args.body.as_deref(),
117
+
&pds,
118
+
&session.access_jwt,
119
+
)
120
+
.await?;
23
121
println!(
24
-
"PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
25
-
args.repo, args.base, args.head, args.title, args.draft
122
+
"Created PR rkey={} targeting {} branch {}",
123
+
rkey, info.did, base
26
124
);
27
125
Ok(())
28
126
}
29
127
30
128
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
-
);
129
+
let mgr = SessionManager::default();
130
+
let session = mgr
131
+
.load()?
132
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
133
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
134
+
let pds = session
135
+
.pds
136
+
.clone()
137
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
138
+
.unwrap_or_else(|| "https://bsky.social".into());
139
+
let client = tangled_api::TangledClient::new(&pds);
140
+
let pr = client
141
+
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
142
+
.await?;
143
+
println!("TITLE: {}", pr.title);
144
+
if !pr.body.is_empty() {
145
+
println!("BODY:\n{}", pr.body);
146
+
}
147
+
println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch);
148
+
if args.diff {
149
+
println!("PATCH:\n{}", pr.patch);
150
+
}
35
151
Ok(())
36
152
}
37
153
38
154
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
-
);
155
+
let mgr = SessionManager::default();
156
+
let session = mgr
157
+
.load()?
158
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
159
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
160
+
let pds = session
161
+
.pds
162
+
.clone()
163
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
164
+
.unwrap_or_else(|| "https://bsky.social".into());
165
+
let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey);
166
+
let note = if let Some(c) = args.comment.as_deref() {
167
+
c
168
+
} else if args.approve {
169
+
"LGTM"
170
+
} else if args.request_changes {
171
+
"Requesting changes"
172
+
} else {
173
+
""
174
+
};
175
+
if note.is_empty() {
176
+
return Err(anyhow!("provide --comment or --approve/--request-changes"));
177
+
}
178
+
let client = tangled_api::TangledClient::new(&pds);
179
+
client
180
+
.comment_pull(&session.did, &pr_at, note, &pds, &session.access_jwt)
181
+
.await?;
182
+
println!("Review comment posted");
43
183
Ok(())
44
184
}
45
185
46
-
async fn merge(args: PrMergeArgs) -> Result<()> {
47
-
println!(
48
-
"PR merge (stub) id={} squash={} rebase={} no_ff={}",
49
-
args.id, args.squash, args.rebase, args.no_ff
50
-
);
186
+
async fn merge(_args: PrMergeArgs) -> Result<()> {
187
+
// Placeholder: merging requires server-side merge call with the patch and target branch.
188
+
println!("Merge via CLI is not implemented yet. Use the web UI for now.");
51
189
Ok(())
52
190
}
191
+
192
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
193
+
if let Some((owner, name)) = spec.split_once('/') {
194
+
if !owner.is_empty() {
195
+
(owner, name)
196
+
} else {
197
+
(default_owner, name)
198
+
}
199
+
} else {
200
+
(default_owner, spec)
201
+
}
202
+
}
203
+
204
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
205
+
if let Some(rest) = id.strip_prefix("at://") {
206
+
let parts: Vec<&str> = rest.split('/').collect();
207
+
if parts.len() >= 4 {
208
+
return Ok((parts[0].to_string(), parts[3].to_string()));
209
+
}
210
+
}
211
+
if let Some((did, rkey)) = id.split_once(':') {
212
+
return Ok((did.to_string(), rkey.to_string()));
213
+
}
214
+
Ok((default_did.to_string(), id.to_string()))
215
+
}
+97
-1
crates/tangled-cli/src/commands/spindle.rs
+97
-1
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 tangled_config::session::SessionManager;
5
7
6
8
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
7
9
match cmd {
···
9
11
SpindleCommand::Config(args) => config(args).await,
10
12
SpindleCommand::Run(args) => run_pipeline(args).await,
11
13
SpindleCommand::Logs(args) => logs(args).await,
14
+
SpindleCommand::Secret(cmd) => secret(cmd).await,
12
15
}
13
16
}
14
17
···
40
43
);
41
44
Ok(())
42
45
}
46
+
47
+
async fn secret(cmd: SpindleSecretCommand) -> Result<()> {
48
+
match cmd {
49
+
SpindleSecretCommand::List(args) => secret_list(args).await,
50
+
SpindleSecretCommand::Add(args) => secret_add(args).await,
51
+
SpindleSecretCommand::Remove(args) => secret_remove(args).await,
52
+
}
53
+
}
54
+
55
+
async fn secret_list(args: SpindleSecretListArgs) -> Result<()> {
56
+
let mgr = SessionManager::default();
57
+
let session = mgr
58
+
.load()?
59
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
60
+
let pds = session
61
+
.pds
62
+
.clone()
63
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
64
+
.unwrap_or_else(|| "https://bsky.social".into());
65
+
let pds_client = tangled_api::TangledClient::new(&pds);
66
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
67
+
let info = pds_client
68
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
69
+
.await?;
70
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
71
+
let api = tangled_api::TangledClient::default(); // base tngl.sh
72
+
let secrets = api
73
+
.list_repo_secrets(&pds, &session.access_jwt, &repo_at)
74
+
.await?;
75
+
if secrets.is_empty() {
76
+
println!("No secrets configured for {}", args.repo);
77
+
} else {
78
+
println!("KEY\tCREATED AT\tCREATED BY");
79
+
for s in secrets {
80
+
println!("{}\t{}\t{}", s.key, s.created_at, s.created_by);
81
+
}
82
+
}
83
+
Ok(())
84
+
}
85
+
86
+
async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
87
+
let mgr = SessionManager::default();
88
+
let session = mgr
89
+
.load()?
90
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
91
+
let pds = session
92
+
.pds
93
+
.clone()
94
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
95
+
.unwrap_or_else(|| "https://bsky.social".into());
96
+
let pds_client = tangled_api::TangledClient::new(&pds);
97
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
98
+
let info = pds_client
99
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
100
+
.await?;
101
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
102
+
let api = tangled_api::TangledClient::default();
103
+
api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value)
104
+
.await?;
105
+
println!("Added secret '{}' to {}", args.key, args.repo);
106
+
Ok(())
107
+
}
108
+
109
+
async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
110
+
let mgr = SessionManager::default();
111
+
let session = mgr
112
+
.load()?
113
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
114
+
let pds = session
115
+
.pds
116
+
.clone()
117
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
118
+
.unwrap_or_else(|| "https://bsky.social".into());
119
+
let pds_client = tangled_api::TangledClient::new(&pds);
120
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
121
+
let info = pds_client
122
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
123
+
.await?;
124
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
125
+
let api = tangled_api::TangledClient::default();
126
+
api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key)
127
+
.await?;
128
+
println!("Removed secret '{}' from {}", args.key, args.repo);
129
+
Ok(())
130
+
}
131
+
132
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
133
+
if let Some((owner, name)) = spec.split_once('/') {
134
+
(owner, name)
135
+
} else {
136
+
(default_owner, spec)
137
+
}
138
+
}