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