CLI: issues, PRs, spindle secrets; polish

Changed files
+1081 -42
crates
tangled-api
tangled-cli
+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", &params, 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", &params, 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", &params, 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", &params, 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", &params, 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 + &params, 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
··· 1 1 pub mod client; 2 2 3 3 pub use client::TangledClient; 4 + pub use client::{ 5 + CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, 6 + RepoRecord, Repository, Secret, 7 + };
+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
··· 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
··· 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
··· 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 + }