1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::Path;
137use std::sync::Arc;
138use std::time::Duration;
139
140pub use vcs_cli_support::{
144 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
145 OutputBudget, Secret, StaticCredential, provider_fn,
146};
147pub use processkit::{
157 Error, ErrorKind, ErrorReason, JobRunner, ProcessResult, ProcessRunner, Result,
158};
159pub use processkit::CancellationToken;
163
164mod parse;
165pub use parse::{
166 CheckBucket, CheckRun, Comment, Issue, PrFeedback, PullRequest, Release, RepoView, Review,
167 Workflow, WorkflowRun,
168};
169pub use vcs_diff::{ChangeKind, DiffLine, FileDiff, Hunk};
174pub use vcs_diff::Version as GitHubVersion;
179
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum PrListState {
184 #[default]
186 Open,
187 Closed,
189 Merged,
191 All,
193}
194
195impl PrListState {
196 fn as_arg(self) -> &'static str {
197 match self {
198 Self::Open => "open",
199 Self::Closed => "closed",
200 Self::Merged => "merged",
201 Self::All => "all",
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct PrList {
210 pub state: PrListState,
212 pub limit: usize,
214}
215
216impl PrList {
217 pub fn new() -> Self {
220 Self::default()
221 }
222
223 pub fn state(mut self, state: PrListState) -> Self {
225 self.state = state;
226 self
227 }
228
229 pub fn limit(mut self, limit: usize) -> Self {
231 self.limit = limit;
232 self
233 }
234}
235
236impl Default for PrList {
237 fn default() -> Self {
238 Self {
239 state: PrListState::Open,
240 limit: 100,
241 }
242 }
243}
244
245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
247#[non_exhaustive]
248pub enum IssueListState {
249 #[default]
251 Open,
252 Closed,
254 All,
256}
257
258impl IssueListState {
259 fn as_arg(self) -> &'static str {
260 match self {
261 Self::Open => "open",
262 Self::Closed => "closed",
263 Self::All => "all",
264 }
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270#[non_exhaustive]
271pub struct IssueList {
272 pub state: IssueListState,
274 pub limit: usize,
276}
277
278impl IssueList {
279 pub fn new() -> Self {
282 Self::default()
283 }
284
285 pub fn state(mut self, state: IssueListState) -> Self {
287 self.state = state;
288 self
289 }
290
291 pub fn limit(mut self, limit: usize) -> Self {
293 self.limit = limit;
294 self
295 }
296}
297
298impl Default for IssueList {
299 fn default() -> Self {
300 Self {
301 state: IssueListState::Open,
302 limit: 100,
303 }
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
309#[non_exhaustive]
310pub struct WorkflowList {
311 pub include_disabled: bool,
314 pub limit: usize,
316}
317
318impl WorkflowList {
319 pub fn new() -> Self {
322 Self::default()
323 }
324
325 pub fn all(mut self) -> Self {
327 self.include_disabled = true;
328 self
329 }
330
331 pub fn limit(mut self, limit: usize) -> Self {
333 self.limit = limit;
334 self
335 }
336}
337
338impl Default for WorkflowList {
339 fn default() -> Self {
340 Self {
341 include_disabled: false,
342 limit: 50,
343 }
344 }
345}
346
347pub const BINARY: &str = "gh";
349
350const PR_FIELDS: &str = "number,title,state,isDraft,headRefName,baseRefName,url,labels,assignees,author,createdAt,updatedAt,milestone";
351const REPO_FIELDS: &str = "name,owner,description,url,isPrivate,defaultBranchRef";
352const ISSUE_LIST_FIELDS: &str =
353 "number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
354const ISSUE_VIEW_FIELDS: &str =
355 "number,title,state,body,url,labels,assignees,author,createdAt,updatedAt,milestone";
356const RUN_FIELDS: &str =
357 "databaseId,name,displayTitle,status,conclusion,workflowName,headBranch,event,url,createdAt";
358const WORKFLOW_FIELDS: &str = "id,name,path,state";
359const WORKFLOW_VIEW_LOOKUP_LIMIT: usize = i32::MAX as usize;
363const RUN_WATCH_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
367const CHECK_FIELDS: &str = "name,state,bucket,workflow,link,startedAt,completedAt";
368const RELEASE_LIST_FIELDS: &str = "tagName,name,isLatest,isDraft,isPrerelease,publishedAt";
369const RELEASE_VIEW_FIELDS: &str = "tagName,name,body,url,publishedAt,isDraft,isPrerelease,author";
370
371fn reject_flag_like(what: &str, value: &str) -> Result<()> {
379 vcs_cli_support::reject_flag_like(BINARY, what, value)
380}
381
382fn reject_zero_limit(operation: &str, limit: usize) -> Result<()> {
383 if limit == 0 {
384 return Err(Error::spawn(
385 BINARY,
386 std::io::Error::new(
387 std::io::ErrorKind::InvalidInput,
388 format!("{operation} limit must be greater than zero"),
389 ),
390 ));
391 }
392 Ok(())
393}
394
395fn reject_invalid_labels(operation: &str, labels: &[String]) -> Result<()> {
399 if labels.is_empty() || labels.iter().any(|label| label.trim().is_empty()) {
400 return Err(Error::spawn(
401 BINARY,
402 std::io::Error::new(
403 std::io::ErrorKind::InvalidInput,
404 format!("{operation} requires at least one non-empty label"),
405 ),
406 ));
407 }
408 Ok(())
409}
410
411fn reject_invalid_workflow_dispatch_fields(fields: &[(String, String)]) -> Result<()> {
415 for (key, _) in fields {
416 let reason = if key.trim().is_empty() {
417 "must not be empty"
418 } else if key.contains('=') {
419 "must not contain `=`"
420 } else if key.contains('\0') {
421 "must not contain NUL"
422 } else {
423 continue;
424 };
425 return Err(Error::spawn(
426 BINARY,
427 std::io::Error::new(
428 std::io::ErrorKind::InvalidInput,
429 format!("workflow_dispatch input key {key:?} {reason}"),
430 ),
431 ));
432 }
433 Ok(())
434}
435
436fn resolve_workflow(workflows: Vec<Workflow>, selector: &str) -> Result<Workflow> {
437 if selector.is_empty() {
438 return Err(Error::spawn(
439 BINARY,
440 std::io::Error::new(
441 std::io::ErrorKind::InvalidInput,
442 "workflow_view selector must not be empty",
443 ),
444 ));
445 }
446
447 let numeric_id = selector.parse::<u64>().ok();
448 let selector_lower = selector.to_lowercase();
449 let is_file = selector_lower.ends_with(".yml") || selector_lower.ends_with(".yaml");
450 let mut matches: Vec<_> = workflows
451 .into_iter()
452 .filter(|workflow| {
453 if let Some(id) = numeric_id {
454 workflow.id == id
455 } else if is_file {
456 workflow.path == selector
457 || workflow
458 .path
459 .rsplit('/')
460 .next()
461 .is_some_and(|file| file == selector)
462 } else {
463 workflow.name.to_lowercase() == selector_lower
464 }
465 })
466 .collect();
467
468 match matches.len() {
469 1 => Ok(matches.pop().expect("length checked")),
470 0 => Err(Error::parse(
471 BINARY,
472 format!("could not find workflow {selector:?}"),
473 )),
474 count => Err(Error::parse(
475 BINARY,
476 format!("workflow selector {selector:?} is ambiguous ({count} matches)"),
477 )),
478 }
479}
480
481#[derive(Clone, Debug, PartialEq, Eq)]
511pub struct GitHubHost {
512 host: String,
514 enterprise: bool,
516}
517
518impl GitHubHost {
519 pub const SAAS_HOST: &'static str = "github.com";
521
522 #[must_use]
524 pub fn github_com() -> Self {
525 Self {
526 host: Self::SAAS_HOST.to_string(),
527 enterprise: false,
528 }
529 }
530
531 pub fn new(host: impl AsRef<str>) -> Result<Self> {
537 let host = validate_host(host.as_ref())?;
538 let enterprise = host != Self::SAAS_HOST;
539 Ok(Self { host, enterprise })
540 }
541
542 pub fn from_remote_url(url: &str) -> Result<Self> {
550 match host_from_remote_url(url) {
551 Some(host) => Self::new(host),
552 None => Err(invalid_host_error(
553 url,
554 "no GitHub host could be determined from the remote URL",
555 )),
556 }
557 }
558
559 #[must_use]
561 pub fn as_str(&self) -> &str {
562 &self.host
563 }
564
565 #[must_use]
567 pub fn is_enterprise(&self) -> bool {
568 self.enterprise
569 }
570
571 #[must_use]
573 pub fn is_github_com(&self) -> bool {
574 !self.enterprise
575 }
576
577 fn token_env_var(&self) -> &'static str {
581 if self.enterprise {
582 "GH_ENTERPRISE_TOKEN"
583 } else {
584 "GH_TOKEN"
585 }
586 }
587}
588
589fn validate_host(host: &str) -> Result<String> {
596 let trimmed = host.trim();
597 let well_formed = !trimmed.is_empty()
598 && !trimmed.starts_with('-')
599 && !trimmed.starts_with('.')
600 && !trimmed.ends_with('.')
601 && trimmed
602 .chars()
603 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
604 if !well_formed {
605 return Err(invalid_host_error(host, "not a valid GitHub hostname"));
606 }
607 Ok(trimmed.to_ascii_lowercase())
608}
609
610fn invalid_host_error(value: &str, reason: &str) -> Error {
614 Error::spawn(
615 BINARY,
616 std::io::Error::new(
617 std::io::ErrorKind::InvalidInput,
618 format!("GitHub host {value:?}: {reason}"),
619 ),
620 )
621}
622
623fn host_from_remote_url(url: &str) -> Option<String> {
630 let url = url.trim();
631 if url.is_empty() {
632 return None;
633 }
634 if let Some((_scheme, rest)) = url.split_once("://") {
637 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
638 let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
639 return strip_port(host_port);
640 }
641 if let Some((authority, _path)) = url.split_once(':') {
643 let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
644 if host.contains('.') && !host.contains('/') && !host.contains('\\') {
648 return Some(host.to_string());
649 }
650 }
651 None
652}
653
654fn strip_port(host_port: &str) -> Option<String> {
658 if host_port.is_empty() || host_port.starts_with('[') {
659 return None;
660 }
661 Some(
662 host_port
663 .split_once(':')
664 .map_or(host_port, |(h, _)| h)
665 .to_string(),
666 )
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672#[non_exhaustive]
673pub enum MergeStrategy {
674 Merge,
676 Squash,
678 Rebase,
680}
681
682impl MergeStrategy {
683 fn flag(self) -> &'static str {
684 match self {
685 MergeStrategy::Merge => "--merge",
686 MergeStrategy::Squash => "--squash",
687 MergeStrategy::Rebase => "--rebase",
688 }
689 }
690}
691
692#[derive(Debug, Clone)]
699#[non_exhaustive]
700pub struct PrMerge {
701 pub strategy: MergeStrategy,
703 pub auto: bool,
705 pub delete_branch: bool,
707}
708
709impl PrMerge {
710 pub fn merge() -> Self {
712 Self::with(MergeStrategy::Merge)
713 }
714
715 pub fn squash() -> Self {
717 Self::with(MergeStrategy::Squash)
718 }
719
720 pub fn rebase() -> Self {
722 Self::with(MergeStrategy::Rebase)
723 }
724
725 fn with(strategy: MergeStrategy) -> Self {
726 Self {
727 strategy,
728 auto: false,
729 delete_branch: false,
730 }
731 }
732
733 pub fn auto(mut self) -> Self {
735 self.auto = true;
736 self
737 }
738
739 pub fn delete_branch(mut self) -> Self {
741 self.delete_branch = true;
742 self
743 }
744}
745
746#[derive(Debug, Clone, Default, PartialEq, Eq)]
752#[non_exhaustive]
753pub struct PrClose {
754 pub delete_branch: bool,
756}
757
758impl PrClose {
759 pub fn new() -> Self {
761 Self::default()
762 }
763
764 pub fn delete_branch(mut self) -> Self {
766 self.delete_branch = true;
767 self
768 }
769}
770
771#[derive(Debug, Clone)]
777#[non_exhaustive]
778pub struct PrCreate {
779 pub title: String,
781 pub body: String,
783 pub head: Option<String>,
785 pub base: Option<String>,
787 pub labels: Vec<String>,
789}
790
791impl PrCreate {
792 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
795 Self {
796 title: title.into(),
797 body: body.into(),
798 head: None,
799 base: None,
800 labels: Vec::new(),
801 }
802 }
803
804 pub fn head(mut self, head: impl Into<String>) -> Self {
806 self.head = Some(head.into());
807 self
808 }
809
810 pub fn base(mut self, base: impl Into<String>) -> Self {
812 self.base = Some(base.into());
813 self
814 }
815
816 pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
818 self.labels = labels.into();
819 self
820 }
821}
822
823#[derive(Debug, Clone, PartialEq, Eq)]
825#[non_exhaustive]
826pub struct IssueCreate {
827 pub title: String,
829 pub body: String,
831 pub labels: Vec<String>,
833}
834
835impl IssueCreate {
836 pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
838 Self {
839 title: title.into(),
840 body: body.into(),
841 labels: Vec::new(),
842 }
843 }
844
845 pub fn labels(mut self, labels: impl Into<Vec<String>>) -> Self {
847 self.labels = labels.into();
848 self
849 }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
861#[non_exhaustive]
862pub struct PrEdit {
863 pub title: Option<String>,
865 pub body: Option<String>,
867}
868
869impl PrEdit {
870 pub fn new() -> Self {
874 Self {
875 title: None,
876 body: None,
877 }
878 }
879
880 pub fn title(mut self, title: impl Into<String>) -> Self {
882 self.title = Some(title.into());
883 self
884 }
885
886 pub fn body(mut self, body: impl Into<String>) -> Self {
888 self.body = Some(body.into());
889 self
890 }
891}
892
893impl Default for PrEdit {
894 fn default() -> Self {
895 Self::new()
896 }
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
902#[non_exhaustive]
903pub enum ReviewKind {
904 Approve,
906 RequestChanges,
908 Comment,
910}
911
912#[derive(Debug, Clone, PartialEq, Eq)]
923#[non_exhaustive]
924pub struct ReviewAction {
925 kind: ReviewKind,
926 body: Option<String>,
927}
928
929impl ReviewAction {
930 pub fn approve() -> Self {
933 Self {
934 kind: ReviewKind::Approve,
935 body: None,
936 }
937 }
938
939 pub fn request_changes(body: impl Into<String>) -> Self {
942 Self {
943 kind: ReviewKind::RequestChanges,
944 body: Some(body.into()),
945 }
946 }
947
948 pub fn comment(body: impl Into<String>) -> Self {
950 Self {
951 kind: ReviewKind::Comment,
952 body: Some(body.into()),
953 }
954 }
955
956 pub fn with_body(mut self, body: impl Into<String>) -> Self {
959 self.body = Some(body.into());
960 self
961 }
962
963 pub fn kind(&self) -> ReviewKind {
965 self.kind
966 }
967
968 pub fn body(&self) -> Option<&str> {
970 self.body.as_deref()
971 }
972}
973
974#[derive(Debug, Clone)]
982#[non_exhaustive]
983pub struct ReleaseCreate {
984 pub tag: String,
987 pub title: Option<String>,
989 pub notes: Option<String>,
994 pub draft: bool,
996 pub prerelease: bool,
998}
999
1000impl ReleaseCreate {
1001 pub fn new(tag: impl Into<String>) -> Self {
1004 Self {
1005 tag: tag.into(),
1006 title: None,
1007 notes: None,
1008 draft: false,
1009 prerelease: false,
1010 }
1011 }
1012
1013 pub fn title(mut self, title: impl Into<String>) -> Self {
1015 self.title = Some(title.into());
1016 self
1017 }
1018
1019 pub fn notes(mut self, notes: impl Into<String>) -> Self {
1021 self.notes = Some(notes.into());
1022 self
1023 }
1024
1025 pub fn draft(mut self) -> Self {
1027 self.draft = true;
1028 self
1029 }
1030
1031 pub fn prerelease(mut self) -> Self {
1033 self.prerelease = true;
1034 self
1035 }
1036}
1037
1038#[derive(Debug, Clone)]
1055#[non_exhaustive]
1056pub struct WorkflowDispatch {
1057 pub workflow: String,
1062 pub git_ref: Option<String>,
1067 pub fields: Vec<(String, String)>,
1073}
1074
1075impl WorkflowDispatch {
1076 pub fn new(workflow: impl Into<String>) -> Self {
1080 Self {
1081 workflow: workflow.into(),
1082 git_ref: None,
1083 fields: Vec::new(),
1084 }
1085 }
1086
1087 pub fn git_ref(mut self, git_ref: impl Into<String>) -> Self {
1090 self.git_ref = Some(git_ref.into());
1091 self
1092 }
1093
1094 pub fn field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1097 self.fields.push((key.into(), value.into()));
1098 self
1099 }
1100}
1101
1102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1107#[non_exhaustive]
1108pub enum RerunScope {
1109 All,
1111 FailedOnly,
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1122#[non_exhaustive]
1123pub struct GitHubCapabilities {
1124 pub version: GitHubVersion,
1126}
1127
1128const MIN_SUPPORTED: GitHubVersion = GitHubVersion {
1137 major: 2,
1138 minor: 0,
1139 patch: 0,
1140};
1141
1142impl GitHubCapabilities {
1143 pub fn is_supported(&self) -> bool {
1146 self.version >= MIN_SUPPORTED
1147 }
1148
1149 pub fn ensure_supported(&self) -> Result<()> {
1154 if self.is_supported() {
1155 return Ok(());
1156 }
1157 Err(Error::spawn(
1158 BINARY,
1159 std::io::Error::new(
1160 std::io::ErrorKind::Unsupported,
1161 format!(
1162 "vcs-github requires gh >= {MIN_SUPPORTED}, found {}",
1163 self.version
1164 ),
1165 ),
1166 ))
1167 }
1168}
1169
1170#[cfg_attr(feature = "mock", mockall::automock)]
1173#[async_trait::async_trait]
1174pub trait GitHubApi: Send + Sync {
1175 async fn run(&self, args: &[String]) -> Result<String>;
1184 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
1187 async fn version(&self) -> Result<String>;
1189 async fn capabilities(&self) -> Result<GitHubCapabilities>;
1194 async fn auth_status(&self) -> Result<bool>;
1201 #[allow(unused_variables)]
1213 async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
1214 Err(Error::from(ErrorReason::Unsupported {
1215 operation: "auth_status_for".into(),
1216 }))
1217 }
1218 async fn repo_view(&self, dir: &Path) -> Result<RepoView>;
1220 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
1223 #[allow(unused_variables)]
1227 async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
1228 Err(Error::from(ErrorReason::Unsupported {
1229 operation: "pr_list_with".into(),
1230 }))
1231 }
1232 #[allow(unused_variables)]
1240 async fn pr_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
1241 Err(Error::from(ErrorReason::Unsupported {
1242 operation: "pr_list_for_source_branch".into(),
1243 }))
1244 }
1245 async fn pr_list_for_branch(
1250 &self,
1251 dir: &Path,
1252 head: &str,
1253 base: &str,
1254 ) -> Result<Vec<PullRequest>>;
1255 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
1257 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
1260 #[allow(unused_variables)]
1264 async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
1265 Err(Error::from(ErrorReason::Unsupported {
1266 operation: "issue_list_with".into(),
1267 }))
1268 }
1269 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
1273 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String>;
1277
1278 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()>;
1283 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()>;
1285 async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()>;
1288 #[allow(unused_variables)]
1290 async fn pr_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1291 Err(Error::from(ErrorReason::Unsupported {
1292 operation: "pr_add_labels".into(),
1293 }))
1294 }
1295 #[allow(unused_variables)]
1297 async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1298 Err(Error::from(ErrorReason::Unsupported {
1299 operation: "pr_remove_labels".into(),
1300 }))
1301 }
1302 #[allow(unused_variables)]
1309 async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
1310 Err(Error::from(ErrorReason::Unsupported {
1311 operation: "pr_checkout".into(),
1312 }))
1313 }
1314 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>>;
1321 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()>;
1325 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String>;
1328 #[allow(unused_variables)]
1336 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
1337 Err(Error::from(ErrorReason::Unsupported {
1338 operation: "pr_edit".into(),
1339 }))
1340 }
1341 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback>;
1344 async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>>;
1349
1350 #[allow(unused_variables)]
1357 async fn workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
1358 Err(Error::from(ErrorReason::Unsupported {
1359 operation: "workflow_list".into(),
1360 }))
1361 }
1362 #[allow(unused_variables)]
1366 async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
1367 Err(Error::from(ErrorReason::Unsupported {
1368 operation: "workflow_list_with".into(),
1369 }))
1370 }
1371 #[allow(unused_variables)]
1378 async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
1379 Err(Error::from(ErrorReason::Unsupported {
1380 operation: "workflow_view".into(),
1381 }))
1382 }
1383
1384 async fn run_list(
1388 &self,
1389 dir: &Path,
1390 limit: u64,
1391 branch: Option<String>,
1392 ) -> Result<Vec<WorkflowRun>>;
1393 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
1396 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun>;
1406 #[allow(unused_variables)]
1426 async fn workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
1427 Err(Error::from(ErrorReason::Unsupported {
1428 operation: "workflow_dispatch".into(),
1429 }))
1430 }
1431 #[allow(unused_variables)]
1447 async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
1448 Err(Error::from(ErrorReason::Unsupported {
1449 operation: "run_rerun".into(),
1450 }))
1451 }
1452 #[allow(unused_variables)]
1468 async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
1469 Err(Error::from(ErrorReason::Unsupported {
1470 operation: "run_cancel".into(),
1471 }))
1472 }
1473
1474 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
1479 async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
1483 if spec.labels.is_empty() {
1484 self.issue_create(dir, &spec.title, &spec.body).await
1485 } else {
1486 Err(Error::from(ErrorReason::Unsupported {
1487 operation: "issue_create_with(labels)".into(),
1488 }))
1489 }
1490 }
1491 #[allow(unused_variables)]
1493 async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1494 Err(Error::from(ErrorReason::Unsupported {
1495 operation: "issue_add_labels".into(),
1496 }))
1497 }
1498 #[allow(unused_variables)]
1500 async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
1501 Err(Error::from(ErrorReason::Unsupported {
1502 operation: "issue_remove_labels".into(),
1503 }))
1504 }
1505 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
1508 #[allow(unused_variables)]
1514 async fn issue_close(&self, dir: &Path, number: u64) -> Result<()> {
1515 Err(Error::from(ErrorReason::Unsupported {
1516 operation: "issue_close".into(),
1517 }))
1518 }
1519 #[allow(unused_variables)]
1525 async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
1526 Err(Error::from(ErrorReason::Unsupported {
1527 operation: "issue_reopen".into(),
1528 }))
1529 }
1530 #[allow(unused_variables)]
1538 async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
1539 Err(Error::from(ErrorReason::Unsupported {
1540 operation: "issue_comment".into(),
1541 }))
1542 }
1543 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
1547 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
1551 #[allow(unused_variables)]
1560 async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
1561 Err(Error::from(ErrorReason::Unsupported {
1562 operation: "release_create".into(),
1563 }))
1564 }
1565 #[allow(unused_variables)]
1571 async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
1572 Err(Error::from(ErrorReason::Unsupported {
1573 operation: "release_delete".into(),
1574 }))
1575 }
1576}
1577
1578vcs_cli_support::managed_client! {
1579 pub struct GitHub => BINARY, token_env = (CredentialService::GitHub, "GH_TOKEN")
1589}
1590
1591impl<R: ProcessRunner> GitHub<R> {
1592 #[must_use]
1596 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
1597 self.core = self.core.with_credentials(provider);
1598 self
1599 }
1600
1601 #[must_use]
1605 pub fn with_token(self, token: impl Into<Secret>) -> Self {
1606 self.with_credentials(Arc::new(StaticCredential::token(token)))
1607 }
1608
1609 #[must_use]
1613 pub fn with_env_token(self, var: impl Into<String>) -> Self {
1614 self.with_credentials(Arc::new(EnvToken::new(var)))
1615 }
1616
1617 #[must_use]
1646 pub fn with_host(mut self, host: GitHubHost) -> Self {
1647 self.core = self
1648 .core
1649 .with_token_env(CredentialService::GitHub, host.token_env_var())
1650 .with_expected_host(host.as_str())
1655 .default_env("GH_HOST", host.as_str());
1656 self
1657 }
1658}
1659
1660#[async_trait::async_trait]
1661impl<R: ProcessRunner> GitHubApi for GitHub<R> {
1662 async fn run(&self, args: &[String]) -> Result<String> {
1663 self.core.run(args).await
1664 }
1665
1666 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1667 self.core.output_string(args).await
1668 }
1669
1670 async fn version(&self) -> Result<String> {
1671 self.core.run(["--version"]).await
1672 }
1673
1674 async fn capabilities(&self) -> Result<GitHubCapabilities> {
1675 let raw = self.version().await?;
1676 let version = parse::parse_gh_version(&raw).ok_or_else(|| {
1677 Error::parse(
1678 BINARY,
1679 format!("unrecognisable `gh --version` output: {raw:?}"),
1680 )
1681 })?;
1682 Ok(GitHubCapabilities { version })
1683 }
1684
1685 async fn auth_status(&self) -> Result<bool> {
1686 Ok(self.core.exit_code(["auth", "status"]).await? == 0)
1692 }
1693
1694 async fn auth_status_for(&self, host: &GitHubHost) -> Result<bool> {
1695 Ok(self
1703 .core
1704 .exit_code(["auth", "status", "--hostname", host.as_str()])
1705 .await?
1706 == 0)
1707 }
1708
1709 async fn repo_view(&self, dir: &Path) -> Result<RepoView> {
1710 self.core
1711 .try_parse(
1712 self.core
1713 .command_in(dir, ["repo", "view", "--json", REPO_FIELDS]),
1714 parse::parse_repo,
1715 )
1716 .await
1717 }
1718
1719 async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
1720 self.pr_list_with(dir, PrList::default()).await
1721 }
1722
1723 async fn pr_list_with(&self, dir: &Path, spec: PrList) -> Result<Vec<PullRequest>> {
1724 reject_zero_limit("pr_list_with", spec.limit)?;
1725 let limit = spec.limit.to_string();
1726 self.core
1727 .try_parse(
1728 self.core.command_in(
1729 dir,
1730 [
1731 "pr",
1732 "list",
1733 "--state",
1734 spec.state.as_arg(),
1735 "--limit",
1736 limit.as_str(),
1737 "--json",
1738 PR_FIELDS,
1739 ],
1740 ),
1741 |s| vcs_cli_support::json::from_json(BINARY, s),
1742 )
1743 .await
1744 }
1745
1746 async fn pr_list_for_branch(
1747 &self,
1748 dir: &Path,
1749 head: &str,
1750 base: &str,
1751 ) -> Result<Vec<PullRequest>> {
1752 reject_flag_like("head", head)?;
1753 reject_flag_like("base", base)?;
1754 self.core
1757 .try_parse(
1758 self.core.command_in(
1759 dir,
1760 [
1761 "pr", "list", "--head", head, "--base", base, "--state", "all", "--limit",
1762 "100", "--json", PR_FIELDS,
1763 ],
1764 ),
1765 |s| vcs_cli_support::json::from_json(BINARY, s),
1766 )
1767 .await
1768 }
1769
1770 async fn pr_list_for_source_branch(&self, dir: &Path, head: &str) -> Result<Vec<PullRequest>> {
1771 reject_flag_like("head", head)?;
1772 self.core
1773 .try_parse(
1774 self.core.command_in(
1775 dir,
1776 [
1777 "pr", "list", "--head", head, "--state", "all", "--limit", "100", "--json",
1778 PR_FIELDS,
1779 ],
1780 ),
1781 |s| vcs_cli_support::json::from_json(BINARY, s),
1782 )
1783 .await
1784 }
1785
1786 async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
1787 let n = number.to_string();
1788 self.core
1789 .try_parse(
1790 self.core
1791 .command_in(dir, ["pr", "view", n.as_str(), "--json", PR_FIELDS]),
1792 |s| vcs_cli_support::json::from_json(BINARY, s),
1793 )
1794 .await
1795 }
1796
1797 async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
1798 self.issue_list_with(dir, IssueList::default()).await
1799 }
1800
1801 async fn issue_list_with(&self, dir: &Path, spec: IssueList) -> Result<Vec<Issue>> {
1802 reject_zero_limit("issue_list_with", spec.limit)?;
1803 let limit = spec.limit.to_string();
1804 self.core
1805 .try_parse(
1806 self.core.command_in(
1807 dir,
1808 [
1809 "issue",
1810 "list",
1811 "--state",
1812 spec.state.as_arg(),
1813 "--limit",
1814 limit.as_str(),
1815 "--json",
1816 ISSUE_LIST_FIELDS,
1817 ],
1818 ),
1819 |s| vcs_cli_support::json::from_json(BINARY, s),
1820 )
1821 .await
1822 }
1823
1824 async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
1825 let mut args = vec![
1826 "pr",
1827 "create",
1828 "--title",
1829 spec.title.as_str(),
1830 "--body",
1831 spec.body.as_str(),
1832 ];
1833 if let Some(head) = spec.head.as_deref() {
1834 args.push("--head");
1835 args.push(head);
1836 }
1837 if let Some(base) = spec.base.as_deref() {
1838 args.push("--base");
1839 args.push(base);
1840 }
1841 if !spec.labels.is_empty() {
1842 reject_invalid_labels("pr_create", &spec.labels)?;
1843 for label in &spec.labels {
1844 args.push("--label");
1845 args.push(label);
1846 }
1847 }
1848 self.core.run(self.core.command_in(dir, args)).await
1849 }
1850
1851 async fn api(&self, dir: &Path, endpoint: &str) -> Result<String> {
1852 reject_flag_like("endpoint", endpoint)?;
1853 self.core
1854 .run(self.core.command_in(dir, ["api", endpoint]))
1855 .await
1856 }
1857
1858 async fn pr_merge(&self, dir: &Path, number: u64, merge: PrMerge) -> Result<()> {
1859 let n = number.to_string();
1860 let mut args = vec!["pr", "merge", n.as_str(), merge.strategy.flag()];
1861 if merge.auto {
1862 args.push("--auto");
1863 }
1864 if merge.delete_branch {
1865 args.push("--delete-branch");
1866 }
1867 self.core.run_unit(self.core.command_in(dir, args)).await
1868 }
1869
1870 async fn pr_mark_ready(&self, dir: &Path, number: u64) -> Result<()> {
1871 let n = number.to_string();
1872 self.core
1873 .run_unit(self.core.command_in(dir, ["pr", "ready", n.as_str()]))
1874 .await
1875 }
1876
1877 async fn pr_close(&self, dir: &Path, number: u64, spec: PrClose) -> Result<()> {
1878 let n = number.to_string();
1879 let mut args = vec!["pr", "close", n.as_str()];
1880 if spec.delete_branch {
1881 args.push("--delete-branch");
1882 }
1883 self.core.run_unit(self.core.command_in(dir, args)).await
1884 }
1885
1886 async fn pr_checkout(&self, dir: &Path, number: u64) -> Result<()> {
1887 let n = number.to_string();
1891 self.core
1892 .run_unit(self.core.command_in(dir, ["pr", "checkout", n.as_str()]))
1893 .await
1894 }
1895
1896 async fn pr_checks(&self, dir: &Path, number: u64) -> Result<Vec<CheckRun>> {
1897 let n = number.to_string();
1898 let res = self
1899 .core
1900 .output_string(
1901 self.core
1902 .command_in(dir, ["pr", "checks", n.as_str(), "--json", CHECK_FIELDS]),
1903 )
1904 .await?;
1905 match res.code() {
1906 Some(0) => vcs_cli_support::json::from_json(BINARY, res.stdout()),
1912 Some(1 | 8) if !res.stdout().trim().is_empty() => {
1913 vcs_cli_support::json::from_json(BINARY, res.stdout())
1914 }
1915 _ if res
1922 .stderr()
1923 .to_ascii_lowercase()
1924 .contains("no checks reported") =>
1925 {
1926 Ok(Vec::new())
1927 }
1928 _ => {
1931 let _ = res.ensure_success()?;
1932 Ok(Vec::new()) }
1934 }
1935 }
1936
1937 async fn pr_review(&self, dir: &Path, number: u64, action: ReviewAction) -> Result<()> {
1938 let n = number.to_string();
1939 let mut args = vec!["pr", "review", n.as_str()];
1940 args.push(match action.kind() {
1941 ReviewKind::Approve => "--approve",
1942 ReviewKind::RequestChanges => "--request-changes",
1943 ReviewKind::Comment => "--comment",
1944 });
1945 if let Some(body) = action.body() {
1946 args.push("--body");
1947 args.push(body);
1948 }
1949 self.core.run_unit(self.core.command_in(dir, args)).await
1950 }
1951
1952 async fn pr_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
1953 let n = number.to_string();
1956 self.core
1957 .run(
1958 self.core
1959 .command_in(dir, ["pr", "comment", n.as_str(), "--body", body]),
1960 )
1961 .await
1962 }
1963
1964 async fn pr_edit(&self, dir: &Path, number: u64, edit: PrEdit) -> Result<()> {
1965 let n = number.to_string();
1971 let mut args = vec!["pr", "edit", n.as_str()];
1972 if let Some(title) = edit.title.as_deref() {
1973 args.push("--title");
1974 args.push(title);
1975 }
1976 if let Some(body) = edit.body.as_deref() {
1977 args.push("--body");
1978 args.push(body);
1979 }
1980 self.core.run_unit(self.core.command_in(dir, args)).await
1981 }
1982
1983 async fn pr_feedback(&self, dir: &Path, number: u64) -> Result<PrFeedback> {
1984 let n = number.to_string();
1985 self.core
1986 .try_parse(
1987 self.core.command_in(
1988 dir,
1989 ["pr", "view", n.as_str(), "--json", "reviews,comments"],
1990 ),
1991 parse::parse_feedback,
1992 )
1993 .await
1994 }
1995
1996 async fn pr_diff(&self, dir: &Path, number: u64) -> Result<Vec<FileDiff>> {
1997 self.pr_diff_within(dir, number, self.core.output_budget())
1998 .await
1999 }
2000
2001 async fn workflow_list(&self, dir: &Path) -> Result<Vec<Workflow>> {
2002 self.workflow_list_with(dir, WorkflowList::default()).await
2003 }
2004
2005 async fn workflow_list_with(&self, dir: &Path, spec: WorkflowList) -> Result<Vec<Workflow>> {
2006 reject_zero_limit("workflow_list_with", spec.limit)?;
2007 let limit = spec.limit.to_string();
2008 let mut args = vec!["workflow", "list", "--limit", limit.as_str()];
2009 if spec.include_disabled {
2010 args.push("--all");
2011 }
2012 args.extend(["--json", WORKFLOW_FIELDS]);
2013 self.core
2014 .try_parse(self.core.command_in(dir, args), |s| {
2015 vcs_cli_support::json::from_json(BINARY, s)
2016 })
2017 .await
2018 }
2019
2020 async fn workflow_view(&self, dir: &Path, selector: &str) -> Result<Workflow> {
2021 if selector.is_empty() {
2022 return resolve_workflow(Vec::new(), selector);
2023 }
2024 let workflows = self
2029 .workflow_list_with(
2030 dir,
2031 WorkflowList::new().all().limit(WORKFLOW_VIEW_LOOKUP_LIMIT),
2032 )
2033 .await?;
2034 resolve_workflow(workflows, selector)
2035 }
2036
2037 async fn run_list(
2038 &self,
2039 dir: &Path,
2040 limit: u64,
2041 branch: Option<String>,
2042 ) -> Result<Vec<WorkflowRun>> {
2043 let limit = limit.to_string();
2044 let mut args = vec!["run", "list", "--limit", limit.as_str()];
2045 if let Some(branch) = branch.as_deref() {
2046 args.push("--branch");
2047 args.push(branch);
2048 }
2049 args.extend(["--json", RUN_FIELDS]);
2050 self.core
2051 .try_parse(self.core.command_in(dir, args), |s| {
2052 vcs_cli_support::json::from_json(BINARY, s)
2053 })
2054 .await
2055 }
2056
2057 async fn run_view(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
2058 let id = id.to_string();
2059 self.core
2060 .try_parse(
2061 self.core
2062 .command_in(dir, ["run", "view", id.as_str(), "--json", RUN_FIELDS]),
2063 |s| vcs_cli_support::json::from_json(BINARY, s),
2064 )
2065 .await
2066 }
2067
2068 async fn run_watch(&self, dir: &Path, id: u64) -> Result<WorkflowRun> {
2069 let id_str = id.to_string();
2078 let watch_budget = OutputBudget::bytes(256 * 1024).with_max_lines(256);
2093 let cmd = self
2094 .core
2095 .command_in(dir, ["run", "watch", id_str.as_str()])
2096 .inactivity_timeout(RUN_WATCH_INACTIVITY_TIMEOUT)
2097 .output_buffer(
2098 watch_budget
2099 .diagnostic_policy()
2100 .expect("a byte/line budget yields a diagnostic policy"),
2101 );
2102 let _ = self.core.output_string(cmd).await?.ensure_success()?;
2103 self.run_view(dir, id).await
2104 }
2105
2106 async fn workflow_dispatch(&self, dir: &Path, spec: WorkflowDispatch) -> Result<()> {
2107 reject_flag_like("workflow", spec.workflow.as_str())?;
2116 reject_invalid_workflow_dispatch_fields(&spec.fields)?;
2117 let fields: Vec<String> = spec
2120 .fields
2121 .iter()
2122 .map(|(k, v)| format!("{k}={v}"))
2123 .collect();
2124 let mut args = vec!["workflow", "run", spec.workflow.as_str()];
2125 if let Some(git_ref) = spec.git_ref.as_deref() {
2126 args.push("--ref");
2127 args.push(git_ref);
2128 }
2129 for field in &fields {
2130 args.push("--raw-field");
2131 args.push(field.as_str());
2132 }
2133 self.core.run_unit(self.core.command_in(dir, args)).await
2134 }
2135
2136 async fn run_rerun(&self, dir: &Path, id: u64, scope: RerunScope) -> Result<()> {
2137 let id = id.to_string();
2140 let mut args = vec!["run", "rerun", id.as_str()];
2141 if scope == RerunScope::FailedOnly {
2142 args.push("--failed");
2143 }
2144 self.core.run_unit(self.core.command_in(dir, args)).await
2145 }
2146
2147 async fn run_cancel(&self, dir: &Path, id: u64) -> Result<()> {
2148 let id = id.to_string();
2151 self.core
2152 .run_unit(self.core.command_in(dir, ["run", "cancel", id.as_str()]))
2153 .await
2154 }
2155
2156 async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
2157 self.issue_create_with(dir, IssueCreate::new(title, body))
2158 .await
2159 }
2160
2161 async fn issue_create_with(&self, dir: &Path, spec: IssueCreate) -> Result<String> {
2162 if !spec.labels.is_empty() {
2163 reject_invalid_labels("issue_create_with", &spec.labels)?;
2164 }
2165 let mut args = vec![
2166 "issue",
2167 "create",
2168 "--title",
2169 spec.title.as_str(),
2170 "--body",
2171 spec.body.as_str(),
2172 ];
2173 for label in &spec.labels {
2174 args.push("--label");
2175 args.push(label);
2176 }
2177 self.core.run(self.core.command_in(dir, args)).await
2178 }
2179
2180 async fn pr_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2181 reject_invalid_labels("pr_add_labels", labels)?;
2182 let number = number.to_string();
2183 let mut args = vec!["pr", "edit", number.as_str()];
2184 for label in labels {
2185 args.push("--add-label");
2186 args.push(label);
2187 }
2188 self.core.run_unit(self.core.command_in(dir, args)).await
2189 }
2190
2191 async fn pr_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2192 reject_invalid_labels("pr_remove_labels", labels)?;
2193 let number = number.to_string();
2194 let mut args = vec!["pr", "edit", number.as_str()];
2195 for label in labels {
2196 args.push("--remove-label");
2197 args.push(label);
2198 }
2199 self.core.run_unit(self.core.command_in(dir, args)).await
2200 }
2201
2202 async fn issue_add_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2203 reject_invalid_labels("issue_add_labels", labels)?;
2204 let number = number.to_string();
2205 let mut args = vec!["issue", "edit", number.as_str()];
2206 for label in labels {
2207 args.push("--add-label");
2208 args.push(label);
2209 }
2210 self.core.run_unit(self.core.command_in(dir, args)).await
2211 }
2212
2213 async fn issue_remove_labels(&self, dir: &Path, number: u64, labels: &[String]) -> Result<()> {
2214 reject_invalid_labels("issue_remove_labels", labels)?;
2215 let number = number.to_string();
2216 let mut args = vec!["issue", "edit", number.as_str()];
2217 for label in labels {
2218 args.push("--remove-label");
2219 args.push(label);
2220 }
2221 self.core.run_unit(self.core.command_in(dir, args)).await
2222 }
2223
2224 async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
2225 let n = number.to_string();
2226 self.core
2227 .try_parse(
2228 self.core.command_in(
2229 dir,
2230 ["issue", "view", n.as_str(), "--json", ISSUE_VIEW_FIELDS],
2231 ),
2232 |s| vcs_cli_support::json::from_json(BINARY, s),
2233 )
2234 .await
2235 }
2236
2237 async fn issue_close(&self, dir: &Path, number: u64) -> Result<()> {
2238 let n = number.to_string();
2239 self.core
2240 .run_unit(self.core.command_in(dir, ["issue", "close", n.as_str()]))
2241 .await
2242 }
2243
2244 async fn issue_reopen(&self, dir: &Path, number: u64) -> Result<()> {
2245 let n = number.to_string();
2246 self.core
2247 .run_unit(self.core.command_in(dir, ["issue", "reopen", n.as_str()]))
2248 .await
2249 }
2250
2251 async fn issue_comment(&self, dir: &Path, number: u64, body: &str) -> Result<String> {
2252 let n = number.to_string();
2256 self.core
2257 .run(
2258 self.core
2259 .command_in(dir, ["issue", "comment", n.as_str(), "--body", body]),
2260 )
2261 .await
2262 }
2263
2264 async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
2265 self.core
2266 .try_parse(
2267 self.core.command_in(
2268 dir,
2269 [
2270 "release",
2271 "list",
2272 "--limit",
2273 "100",
2274 "--json",
2275 RELEASE_LIST_FIELDS,
2276 ],
2277 ),
2278 |s| vcs_cli_support::json::from_json(BINARY, s),
2279 )
2280 .await
2281 }
2282
2283 async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
2284 reject_flag_like("tag", tag)?;
2285 self.core
2286 .try_parse(
2287 self.core
2288 .command_in(dir, ["release", "view", tag, "--json", RELEASE_VIEW_FIELDS]),
2289 |s| vcs_cli_support::json::from_json(BINARY, s),
2290 )
2291 .await
2292 }
2293
2294 async fn release_create(&self, dir: &Path, spec: ReleaseCreate) -> Result<String> {
2295 reject_flag_like("tag", spec.tag.as_str())?;
2300 let mut args = vec!["release", "create", spec.tag.as_str()];
2301 if let Some(title) = spec.title.as_deref() {
2302 args.push("--title");
2303 args.push(title);
2304 }
2305 if let Some(notes) = spec.notes.as_deref() {
2306 args.push("--notes");
2307 args.push(notes);
2308 }
2309 if spec.draft {
2310 args.push("--draft");
2311 }
2312 if spec.prerelease {
2313 args.push("--prerelease");
2314 }
2315 self.core.run(self.core.command_in(dir, args)).await
2316 }
2317
2318 async fn release_delete(&self, dir: &Path, tag: &str) -> Result<()> {
2319 reject_flag_like("tag", tag)?;
2322 self.core
2323 .run_unit(
2324 self.core
2325 .command_in(dir, ["release", "delete", tag, "--yes"]),
2326 )
2327 .await
2328 }
2329}
2330
2331impl<R: ProcessRunner> GitHub<R> {
2332 pub async fn pr_diff_within(
2339 &self,
2340 dir: &Path,
2341 number: u64,
2342 budget: OutputBudget,
2343 ) -> Result<Vec<FileDiff>> {
2344 let n = number.to_string();
2349 let text = self
2350 .core
2351 .run_untrimmed_within(
2352 self.core
2353 .command_in(dir, ["pr", "diff", n.as_str(), "--color", "never"]),
2354 budget,
2355 )
2356 .await?;
2357 Ok(vcs_diff::parse_diff(&text))
2358 }
2359
2360 pub fn at<'a>(&'a self, dir: &'a Path) -> GitHubAt<'a, R> {
2364 GitHubAt { gh: self, dir }
2365 }
2366}
2367
2368vcs_cli_support::raw_run_forwarders! {
2373 GitHub, "gh", "\"pr\", \"list\"", ", so `gh` infers the repo from `dir`'s remote",
2374 "only the working directory is bound, no `-R`/extra flag is injected"
2375}
2376
2377pub struct GitHubAt<'a, R: ProcessRunner = processkit::JobRunner> {
2381 gh: &'a GitHub<R>,
2382 dir: &'a Path,
2383}
2384
2385impl<R: ProcessRunner> Clone for GitHubAt<'_, R> {
2389 fn clone(&self) -> Self {
2390 *self
2391 }
2392}
2393impl<R: ProcessRunner> Copy for GitHubAt<'_, R> {}
2394
2395vcs_cli_support::at_forwarders! {
2399 GitHubAt, gh, "GitHub",
2400 bare {
2401 fn version() -> Result<String>;
2402 fn capabilities() -> Result<GitHubCapabilities>;
2403 fn auth_status() -> Result<bool>;
2404 fn auth_status_for(host: &GitHubHost) -> Result<bool>;
2405 }
2406 dir {
2407 fn api(endpoint: &str) -> Result<String>;
2408 fn repo_view() -> Result<RepoView>;
2409 fn pr_list() -> Result<Vec<PullRequest>>;
2410 fn pr_list_with(spec: PrList) -> Result<Vec<PullRequest>>;
2411 fn pr_list_for_source_branch(head: &str) -> Result<Vec<PullRequest>>;
2412 fn pr_list_for_branch(head: &str, base: &str) -> Result<Vec<PullRequest>>;
2413 fn pr_view(number: u64) -> Result<PullRequest>;
2414 fn issue_list() -> Result<Vec<Issue>>;
2415 fn issue_list_with(spec: IssueList) -> Result<Vec<Issue>>;
2416 fn pr_create(spec: PrCreate) -> Result<String>;
2417 fn pr_add_labels(number: u64, labels: &[String]) -> Result<()>;
2418 fn pr_remove_labels(number: u64, labels: &[String]) -> Result<()>;
2419 fn pr_merge(number: u64, merge: PrMerge) -> Result<()>;
2420 fn pr_mark_ready(number: u64) -> Result<()>;
2421 fn pr_close(number: u64, spec: PrClose) -> Result<()>;
2422 fn pr_checkout(number: u64) -> Result<()>;
2423 fn pr_checks(number: u64) -> Result<Vec<CheckRun>>;
2424 fn pr_review(number: u64, action: ReviewAction) -> Result<()>;
2425 fn pr_comment(number: u64, body: &str) -> Result<String>;
2426 fn pr_edit(number: u64, edit: PrEdit) -> Result<()>;
2427 fn pr_feedback(number: u64) -> Result<PrFeedback>;
2428 fn pr_diff(number: u64) -> Result<Vec<FileDiff>>;
2429 fn workflow_list() -> Result<Vec<Workflow>>;
2430 fn workflow_list_with(spec: WorkflowList) -> Result<Vec<Workflow>>;
2431 fn workflow_view(selector: &str) -> Result<Workflow>;
2432 fn run_list(limit: u64, branch: Option<String>) -> Result<Vec<WorkflowRun>>;
2433 fn run_view(id: u64) -> Result<WorkflowRun>;
2434 fn run_watch(id: u64) -> Result<WorkflowRun>;
2435 fn workflow_dispatch(spec: WorkflowDispatch) -> Result<()>;
2436 fn run_rerun(id: u64, scope: RerunScope) -> Result<()>;
2437 fn run_cancel(id: u64) -> Result<()>;
2438 fn issue_create(title: &str, body: &str) -> Result<String>;
2439 fn issue_create_with(spec: IssueCreate) -> Result<String>;
2440 fn issue_add_labels(number: u64, labels: &[String]) -> Result<()>;
2441 fn issue_remove_labels(number: u64, labels: &[String]) -> Result<()>;
2442 fn issue_view(number: u64) -> Result<Issue>;
2443 fn issue_close(number: u64) -> Result<()>;
2444 fn issue_reopen(number: u64) -> Result<()>;
2445 fn issue_comment(number: u64, body: &str) -> Result<String>;
2446 fn release_list() -> Result<Vec<Release>>;
2447 fn release_view(tag: &str) -> Result<Release>;
2448 fn release_create(spec: ReleaseCreate) -> Result<String>;
2449 fn release_delete(tag: &str) -> Result<()>;
2450 }
2451 raw {
2455 fn run(args: &[String]) -> Result<String> => run_in;
2456 fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
2457 fn run_args(args: &[&str]) -> Result<String> => run_args_in;
2458 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
2459 }
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464 use super::*;
2465 use processkit::testing::{RecordReplayRunner, RecordingRunner, Reply, ScriptedRunner};
2466
2467 fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
2471 out.as_ref().err().map(Error::reason)
2472 }
2473
2474 #[test]
2475 fn binary_name_is_gh() {
2476 assert_eq!(BINARY, "gh");
2477 }
2478
2479 fn cassette_path(name: &str) -> std::path::PathBuf {
2484 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2485 .join("tests/cassettes")
2486 .join(name)
2487 }
2488
2489 #[tokio::test]
2493 async fn capability_version_gate_parses_and_gates() {
2494 let gh = GitHub::with_runner(ScriptedRunner::new().on(
2496 ["gh", "--version"],
2497 Reply::ok(
2498 "gh version 2.40.1 (2024-01-05)\nhttps://github.com/cli/cli/releases/tag/v2.40.1\n",
2499 ),
2500 ));
2501 let caps = gh.capabilities().await.expect("capabilities");
2502 assert_eq!(caps.version.to_string(), "2.40.1");
2503 assert!(caps.is_supported());
2504 caps.ensure_supported().expect("supported");
2505
2506 let at_floor = GitHub::with_runner(
2508 ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version 2.0.0\n")),
2509 );
2510 assert!(
2511 at_floor.capabilities().await.unwrap().is_supported(),
2512 "2.0.0 is exactly the floor"
2513 );
2514
2515 let old = GitHub::with_runner(ScriptedRunner::new().on(
2517 ["gh", "--version"],
2518 Reply::ok("gh version 1.14.0 (2021-11-02)\n"),
2519 ));
2520 let caps = old.capabilities().await.expect("capabilities");
2521 assert_eq!(
2522 caps.version,
2523 GitHubVersion {
2524 major: 1,
2525 minor: 14,
2526 patch: 0
2527 }
2528 );
2529 assert!(!caps.is_supported(), "1.14 is below the 2.0 floor");
2530 let err = caps.ensure_supported().expect_err("unsupported");
2531 let ErrorReason::Spawn { source, .. } = err.reason() else {
2532 panic!("expected Spawn, got {err:?}");
2533 };
2534 let message = source.to_string();
2535 assert!(message.contains(">= 2.0.0"), "names the floor: {message}");
2536 assert!(
2537 message.contains("1.14.0"),
2538 "names the found version: {message}"
2539 );
2540
2541 let garbage = GitHub::with_runner(
2543 ScriptedRunner::new().on(["gh", "--version"], Reply::ok("gh version unknowable\n")),
2544 );
2545 let err = garbage.capabilities().await.expect_err("unrecognisable");
2546 assert!(
2547 matches!(err.reason(), ErrorReason::Parse { .. }),
2548 "got {err:?}"
2549 );
2550 }
2551
2552 #[allow(dead_code)]
2554 fn bound_view_is_copy_for_default_runner() {
2555 fn assert_copy<T: Copy>() {}
2556 assert_copy::<GitHubAt<'static, processkit::JobRunner>>();
2557 }
2558
2559 #[tokio::test]
2562 async fn bound_view_matches_dir_taking_calls() {
2563 let dir = Path::new("/repo");
2564 let rec = RecordingRunner::replying(Reply::ok("[]"));
2565 let gh = GitHub::with_runner(&rec);
2566
2567 gh.pr_list_for_branch(dir, "feat", "main").await.unwrap();
2568 gh.at(dir).pr_list_for_branch("feat", "main").await.unwrap();
2569 gh.run_list(dir, 3, None).await.unwrap();
2571 gh.at(dir).run_list(3, None).await.unwrap();
2572 let disp = || WorkflowDispatch::new("ci.yml").git_ref("main");
2574 gh.workflow_dispatch(dir, disp()).await.unwrap();
2575 gh.at(dir).workflow_dispatch(disp()).await.unwrap();
2576
2577 let calls = rec.calls();
2578 assert_eq!(calls[0].args_str(), calls[1].args_str());
2579 assert_eq!(calls[2].args_str(), calls[3].args_str());
2580 assert_eq!(calls[4].args_str(), calls[5].args_str());
2581 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2582 }
2583
2584 #[tokio::test]
2588 async fn bound_view_raw_hatch_runs_in_bound_dir() {
2589 let dir = Path::new("/repo");
2590 let rec = RecordingRunner::replying(Reply::ok(""));
2591 let gh = GitHub::with_runner(&rec);
2592
2593 gh.at(dir)
2595 .run(&["pr".to_string(), "list".to_string()])
2596 .await
2597 .unwrap();
2598 let _ = gh
2599 .at(dir)
2600 .run_raw(&["pr".to_string(), "list".to_string()])
2601 .await
2602 .unwrap();
2603 gh.at(dir).run_args(&["pr", "list"]).await.unwrap();
2604 let _ = gh.at(dir).run_raw_args(&["pr", "list"]).await.unwrap();
2605 gh.run(&["pr".to_string(), "list".to_string()])
2607 .await
2608 .unwrap();
2609 let _ = gh
2610 .run_raw(&["pr".to_string(), "list".to_string()])
2611 .await
2612 .unwrap();
2613 gh.run_args(&["pr", "list"]).await.unwrap();
2614 let _ = gh.run_raw_args(&["pr", "list"]).await.unwrap();
2615
2616 let calls = rec.calls();
2617 for c in &calls[0..4] {
2618 assert_eq!(
2619 c.cwd.as_deref(),
2620 Some(dir),
2621 "raw call through the bound view runs in the bound dir"
2622 );
2623 assert_eq!(c.args_str(), ["pr", "list"]);
2624 }
2625 for c in &calls[4..8] {
2626 assert_eq!(
2627 c.cwd.as_deref(),
2628 None,
2629 "raw call on the client stays in the process cwd"
2630 );
2631 assert_eq!(c.args_str(), ["pr", "list"]);
2632 }
2633 }
2634
2635 #[tokio::test]
2636 async fn run_args_forwards_str_slices() {
2637 let gh =
2638 GitHub::with_runner(ScriptedRunner::new().on(["gh", "api", "user"], Reply::ok("ok\n")));
2639 assert_eq!(gh.run_args(&["api", "user"]).await.unwrap(), "ok");
2640 }
2641
2642 #[tokio::test]
2645 async fn pr_list_parses_scripted_json() {
2646 let json = r#"[{"number":7,"title":"Add X","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"u"}]"#;
2647 let gh =
2648 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "list"], Reply::ok(json)));
2649 let prs = gh.pr_list(Path::new(".")).await.expect("pr_list");
2650 assert_eq!(prs.len(), 1);
2651 assert_eq!(prs[0].number, 7);
2652 assert_eq!(prs[0].base_ref_name, "main");
2653 }
2654
2655 #[tokio::test]
2659 async fn auth_status_reads_exit_code() {
2660 let yes = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::ok("")));
2661 assert!(yes.auth_status().await.unwrap());
2662 let no = GitHub::with_runner(
2663 ScriptedRunner::new().on(["gh", "auth"], Reply::fail(1, "not logged in")),
2664 );
2665 assert!(!no.auth_status().await.unwrap());
2666 let weird =
2668 GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::fail(2, "boom")));
2669 assert!(!weird.auth_status().await.unwrap());
2670 }
2671
2672 #[tokio::test]
2676 async fn auth_status_errors_on_timeout() {
2677 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "auth"], Reply::timeout()));
2678 assert!(matches!(
2679 gh.auth_status().await.unwrap_err().reason(),
2680 ErrorReason::Timeout { .. }
2681 ));
2682 }
2683
2684 #[tokio::test]
2687 async fn pr_create_appends_base_and_returns_url() {
2688 let gh = GitHub::with_runner(ScriptedRunner::new().on(
2689 [
2690 "gh", "pr", "create", "--title", "T", "--body", "B", "--base", "main",
2691 ],
2692 Reply::ok("https://gh/pr/1\n"),
2693 ));
2694 let url = gh
2695 .pr_create(Path::new("."), PrCreate::new("T", "B").base("main"))
2696 .await
2697 .expect("should build `pr create … --base main`");
2698 assert_eq!(url, "https://gh/pr/1");
2699 }
2700
2701 #[tokio::test]
2704 async fn pr_create_appends_head_and_base() {
2705 use processkit::testing::RecordingRunner;
2706 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/9\n"));
2707 let gh = GitHub::with_runner(&rec);
2708 gh.pr_create(
2709 Path::new("/repo"),
2710 PrCreate::new("T", "B").head("feat/x").base("main"),
2711 )
2712 .await
2713 .expect("pr_create");
2714 assert_eq!(
2715 rec.only_call().args_str(),
2716 [
2717 "pr", "create", "--title", "T", "--body", "B", "--head", "feat/x", "--base", "main"
2718 ]
2719 );
2720 }
2721
2722 #[tokio::test]
2725 async fn pr_list_for_branch_filters_and_parses() {
2726 use processkit::testing::RecordingRunner;
2727 let json = r#"[{"number":9,"title":"Merge feat","state":"OPEN","headRefName":"feat/x","baseRefName":"main","url":"https://gh/pr/9"}]"#;
2728 let rec = RecordingRunner::replying(Reply::ok(json));
2729 let gh = GitHub::with_runner(&rec);
2730 let prs = gh
2731 .pr_list_for_branch(Path::new("/repo"), "feat/x", "main")
2732 .await
2733 .expect("pr_list_for_branch");
2734 assert_eq!(prs.len(), 1);
2735 assert_eq!(prs[0].title, "Merge feat");
2736 assert_eq!(prs[0].url, "https://gh/pr/9");
2737 assert_eq!(
2738 rec.only_call().args_str(),
2739 [
2740 "pr", "list", "--head", "feat/x", "--base", "main", "--state", "all", "--limit",
2741 "100", "--json", PR_FIELDS
2742 ]
2743 );
2744 }
2745
2746 #[tokio::test]
2749 async fn pr_list_for_source_branch_filters_all_states_and_guards_head() {
2750 use processkit::testing::RecordingRunner;
2751 let json = r#"[{"number":9,"title":"Merge feat","state":"CLOSED","headRefName":"feat/x","baseRefName":"release","url":"https://gh/pr/9"}]"#;
2752 let rec = RecordingRunner::replying(Reply::ok(json));
2753 let gh = GitHub::with_runner(&rec);
2754 let prs = gh
2755 .pr_list_for_source_branch(Path::new("/repo"), "feat/x")
2756 .await
2757 .expect("pr_list_for_source_branch");
2758 assert_eq!(prs[0].state, "CLOSED");
2759 assert_eq!(
2760 rec.only_call().args_str(),
2761 [
2762 "pr", "list", "--head", "feat/x", "--state", "all", "--limit", "100", "--json",
2763 PR_FIELDS
2764 ]
2765 );
2766
2767 let guarded = GitHub::with_runner(ScriptedRunner::new());
2768 assert!(
2769 guarded
2770 .pr_list_for_source_branch(Path::new("/repo"), "--state=open")
2771 .await
2772 .is_err()
2773 );
2774 assert!(
2776 guarded
2777 .pr_list_for_branch(Path::new("/repo"), "feat", "--state=open")
2778 .await
2779 .is_err()
2780 );
2781 }
2782
2783 #[tokio::test]
2786 async fn list_methods_pin_limit_100() {
2787 let rec = RecordingRunner::replying(Reply::ok("[]"));
2788 let gh = GitHub::with_runner(&rec);
2789 gh.pr_list(Path::new("/r")).await.expect("pr_list");
2790 gh.issue_list(Path::new("/r")).await.expect("issue_list");
2791 gh.release_list(Path::new("/r"))
2792 .await
2793 .expect("release_list");
2794 let calls = rec.calls();
2795 assert_eq!(
2796 calls[0].args_str(),
2797 [
2798 "pr", "list", "--state", "open", "--limit", "100", "--json", PR_FIELDS
2799 ]
2800 );
2801 assert_eq!(
2802 calls[1].args_str(),
2803 [
2804 "issue",
2805 "list",
2806 "--state",
2807 "open",
2808 "--limit",
2809 "100",
2810 "--json",
2811 ISSUE_LIST_FIELDS
2812 ]
2813 );
2814 assert_eq!(
2815 calls[2].args_str(),
2816 [
2817 "release",
2818 "list",
2819 "--limit",
2820 "100",
2821 "--json",
2822 RELEASE_LIST_FIELDS
2823 ]
2824 );
2825 }
2826
2827 #[tokio::test]
2828 async fn list_specs_map_state_and_limit_and_reject_zero() {
2829 let rec = RecordingRunner::replying(Reply::ok("[]"));
2830 let gh = GitHub::with_runner(&rec);
2831 gh.pr_list_with(
2832 Path::new("/r"),
2833 PrList::new().state(PrListState::Merged).limit(7),
2834 )
2835 .await
2836 .expect("merged PR list");
2837 gh.issue_list_with(
2838 Path::new("/r"),
2839 IssueList::new().state(IssueListState::All).limit(9),
2840 )
2841 .await
2842 .expect("all issue list");
2843 let calls = rec.calls();
2844 assert_eq!(
2845 calls[0].args_str(),
2846 [
2847 "pr", "list", "--state", "merged", "--limit", "7", "--json", PR_FIELDS
2848 ]
2849 );
2850 assert_eq!(
2851 calls[1].args_str(),
2852 [
2853 "issue",
2854 "list",
2855 "--state",
2856 "all",
2857 "--limit",
2858 "9",
2859 "--json",
2860 ISSUE_LIST_FIELDS
2861 ]
2862 );
2863
2864 let guarded = RecordingRunner::replying(Reply::ok("[]"));
2865 let gh = GitHub::with_runner(&guarded);
2866 assert!(
2867 gh.pr_list_with(Path::new("/r"), PrList::new().limit(0))
2868 .await
2869 .is_err()
2870 );
2871 assert!(guarded.calls().is_empty(), "zero limit must not spawn");
2872 }
2873
2874 #[tokio::test]
2878 async fn pr_create_omits_base_when_none() {
2879 use processkit::testing::RecordingRunner;
2880 let rec = RecordingRunner::replying(Reply::ok("https://gh/pr/2\n"));
2881 let gh = GitHub::with_runner(&rec);
2882 let url = gh
2883 .pr_create(Path::new("/repo"), PrCreate::new("T", "B"))
2884 .await
2885 .expect("pr_create");
2886 assert_eq!(url, "https://gh/pr/2");
2887
2888 let call = rec.only_call();
2889 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
2890 assert_eq!(
2891 call.args_str(),
2892 ["pr", "create", "--title", "T", "--body", "B"]
2893 );
2894 assert!(!call.has_flag("--base"), "no base was given");
2895 assert!(!call.has_flag("--head"), "no head was given");
2896 }
2897
2898 #[tokio::test]
2900 async fn flag_like_positionals_are_rejected_before_spawning() {
2901 let rec = RecordingRunner::replying(Reply::ok(""));
2902 let gh = GitHub::with_runner(&rec);
2903 assert!(gh.api(Path::new("."), "-evil").await.is_err());
2904 assert!(gh.release_view(Path::new("."), "-evil").await.is_err());
2905 assert!(
2906 gh.api(Path::new("."), "").await.is_err(),
2907 "empty refused too"
2908 );
2909 assert!(rec.calls().is_empty(), "nothing may spawn");
2910 }
2911
2912 #[tokio::test]
2916 async fn release_create_builds_argv_and_returns_url() {
2917 let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v1.2.0\n"));
2918 let gh = GitHub::with_runner(&rec);
2919 let url = gh
2920 .release_create(
2921 Path::new("/repo"),
2922 ReleaseCreate::new("v1.2.0")
2923 .title("v1.2.0")
2924 .notes("Notes")
2925 .draft()
2926 .prerelease(),
2927 )
2928 .await
2929 .expect("release_create");
2930 assert_eq!(url, "https://gh/releases/v1.2.0");
2931 let call = rec.only_call();
2932 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
2933 assert_eq!(
2934 call.args_str(),
2935 [
2936 "release",
2937 "create",
2938 "v1.2.0",
2939 "--title",
2940 "v1.2.0",
2941 "--notes",
2942 "Notes",
2943 "--draft",
2944 "--prerelease"
2945 ]
2946 );
2947 }
2948
2949 #[tokio::test]
2952 async fn release_create_omits_unset_options() {
2953 let rec = RecordingRunner::replying(Reply::ok("https://gh/releases/v2\n"));
2954 let gh = GitHub::with_runner(&rec);
2955 gh.release_create(Path::new("/r"), ReleaseCreate::new("v2"))
2956 .await
2957 .expect("release_create");
2958 let call = rec.only_call();
2959 assert_eq!(call.args_str(), ["release", "create", "v2"]);
2960 assert!(!call.has_flag("--title"));
2961 assert!(!call.has_flag("--notes"));
2962 assert!(!call.has_flag("--draft"));
2963 assert!(!call.has_flag("--prerelease"));
2964 }
2965
2966 #[tokio::test]
2969 async fn release_delete_builds_argv_with_yes() {
2970 let rec = RecordingRunner::replying(Reply::ok(""));
2971 let gh = GitHub::with_runner(&rec);
2972 gh.release_delete(Path::new("/r"), "v1.2.0")
2973 .await
2974 .expect("release_delete");
2975 assert_eq!(
2976 rec.only_call().args_str(),
2977 ["release", "delete", "v1.2.0", "--yes"]
2978 );
2979 }
2980
2981 #[tokio::test]
2984 async fn release_mutators_reject_flag_like_tag() {
2985 let rec = RecordingRunner::replying(Reply::ok(""));
2986 let gh = GitHub::with_runner(&rec);
2987 assert!(
2988 gh.release_create(Path::new("."), ReleaseCreate::new("-evil"))
2989 .await
2990 .is_err()
2991 );
2992 assert!(
2993 gh.release_create(Path::new("."), ReleaseCreate::new(""))
2994 .await
2995 .is_err()
2996 );
2997 assert!(gh.release_delete(Path::new("."), "-evil").await.is_err());
2998 assert!(gh.release_delete(Path::new("."), "").await.is_err());
2999 assert!(rec.calls().is_empty(), "nothing may spawn");
3000 }
3001
3002 #[tokio::test]
3003 async fn api_runs_in_the_bound_repo_dir() {
3004 let rec = RecordingRunner::replying(Reply::ok("{}\n"));
3005 let gh = GitHub::with_runner(&rec);
3006 gh.api(Path::new("/repo"), "repos/o/r/pulls")
3007 .await
3008 .expect("api");
3009 let call = rec.only_call();
3010 assert_eq!(call.args_str(), ["api", "repos/o/r/pulls"]);
3011 assert_eq!(call.cwd, Some(std::path::PathBuf::from("/repo")));
3014 }
3015
3016 #[tokio::test]
3018 async fn pr_merge_builds_strategy_and_flags() {
3019 let rec = RecordingRunner::replying(Reply::ok(""));
3020 let gh = GitHub::with_runner(&rec);
3021 gh.pr_merge(Path::new("/r"), 7, PrMerge::squash().auto().delete_branch())
3022 .await
3023 .expect("pr_merge");
3024 assert_eq!(
3025 rec.only_call().args_str(),
3026 ["pr", "merge", "7", "--squash", "--auto", "--delete-branch"]
3027 );
3028
3029 let bare = RecordingRunner::replying(Reply::ok(""));
3030 let gh = GitHub::with_runner(&bare);
3031 gh.pr_merge(Path::new("/r"), 7, PrMerge::merge())
3032 .await
3033 .expect("pr_merge");
3034 let call = bare.only_call();
3035 assert_eq!(call.args_str(), ["pr", "merge", "7", "--merge"]);
3036 assert!(!call.has_flag("--auto"));
3037 assert!(!call.has_flag("--delete-branch"));
3038 }
3039
3040 #[tokio::test]
3041 async fn pr_mark_ready_and_close_build_args() {
3042 let rec = RecordingRunner::replying(Reply::ok(""));
3043 let gh = GitHub::with_runner(&rec);
3044 gh.pr_mark_ready(Path::new("/r"), 3)
3045 .await
3046 .expect("pr_mark_ready");
3047 gh.pr_close(Path::new("/r"), 3, PrClose::new().delete_branch())
3048 .await
3049 .expect("close");
3050 gh.pr_close(Path::new("/r"), 4, PrClose::new())
3051 .await
3052 .expect("close");
3053 let calls = rec.calls();
3054 assert_eq!(calls[0].args_str(), ["pr", "ready", "3"]);
3055 assert_eq!(calls[1].args_str(), ["pr", "close", "3", "--delete-branch"]);
3056 assert_eq!(calls[2].args_str(), ["pr", "close", "4"]);
3057 }
3058
3059 #[tokio::test]
3061 async fn pr_checkout_builds_args_in_repo_dir() {
3062 let rec = RecordingRunner::replying(Reply::ok(""));
3063 let gh = GitHub::with_runner(&rec);
3064 gh.pr_checkout(Path::new("/repo"), 7)
3065 .await
3066 .expect("pr_checkout");
3067 let call = rec.only_call();
3068 assert_eq!(call.args_str(), ["pr", "checkout", "7"]);
3069 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
3070 let rec = RecordingRunner::replying(Reply::ok(""));
3072 let gh = GitHub::with_runner(&rec);
3073 gh.at(Path::new("/repo"))
3074 .pr_checkout(7)
3075 .await
3076 .expect("pr_checkout");
3077 assert_eq!(rec.only_call().args_str(), ["pr", "checkout", "7"]);
3078 }
3079
3080 #[tokio::test]
3084 async fn pr_checks_parses_all_outcome_exit_codes() {
3085 let json = r#"[{"name":"build","state":"SUCCESS","bucket":"pass",
3086 "workflow":"CI","link":"l","startedAt":"s","completedAt":"c"}]"#;
3087 for reply in [
3088 Reply::ok(json),
3089 Reply::fail(8, "checks pending").with_stdout(json),
3090 Reply::fail(1, "some checks failed").with_stdout(json),
3091 ] {
3092 let gh = GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], reply));
3093 let checks = gh.pr_checks(Path::new("."), 7).await.expect("pr_checks");
3094 assert_eq!(checks.len(), 1);
3095 assert_eq!(checks[0].bucket, CheckBucket::Pass);
3096 }
3097
3098 for stderr in [
3102 "no checks reported on the 'feat/x' branch",
3103 "No Checks Reported on the 'feat/x' branch",
3104 ] {
3105 let gh = GitHub::with_runner(
3106 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(1, stderr)),
3107 );
3108 assert!(
3109 gh.pr_checks(Path::new("."), 7)
3110 .await
3111 .expect("no checks → empty")
3112 .is_empty(),
3113 "no-checks must read as empty for stderr {stderr:?}"
3114 );
3115 }
3116 let gh = GitHub::with_runner(ScriptedRunner::new().on(
3118 ["gh", "pr", "checks"],
3119 Reply::fail(1, "no pull requests found for branch 'feat/x'"),
3120 ));
3121 assert!(matches!(
3122 gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3123 ErrorReason::Exit { .. }
3124 ));
3125
3126 let gh = GitHub::with_runner(
3128 ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::fail(4, "auth required")),
3129 );
3130 assert!(matches!(
3131 gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3132 ErrorReason::Exit { .. }
3133 ));
3134
3135 let gh =
3136 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "checks"], Reply::timeout()));
3137 assert!(matches!(
3138 gh.pr_checks(Path::new("."), 7).await.unwrap_err().reason(),
3139 ErrorReason::Timeout { .. }
3140 ));
3141 }
3142
3143 #[tokio::test]
3146 async fn pr_diff_builds_args_and_parses_scripted_output() {
3147 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3148 let rec = RecordingRunner::replying(Reply::ok(out));
3149 let gh = GitHub::with_runner(&rec);
3150 let files = gh.pr_diff(Path::new("/r"), 7).await.expect("pr_diff");
3151 assert_eq!(files.len(), 1);
3152 assert_eq!(files[0].path, std::path::Path::new("m"));
3153 assert_eq!(files[0].change, ChangeKind::Modified);
3154 assert_eq!(
3155 rec.only_call().args_str(),
3156 ["pr", "diff", "7", "--color", "never"]
3157 );
3158 }
3159
3160 #[tokio::test]
3167 async fn pr_diff_over_budget_errors_output_too_large() {
3168 let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
3169 assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
3170 let gh =
3171 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(&big)))
3172 .default_output_budget(OutputBudget::bytes(64 * 1024));
3173 match gh
3174 .pr_diff(Path::new("/r"), 7)
3175 .await
3176 .map_err(Error::into_reason)
3177 {
3178 Err(ErrorReason::OutputTooLarge {
3179 program,
3180 max_bytes,
3181 total_bytes,
3182 ..
3183 }) => {
3184 assert_eq!(program, "gh");
3185 assert_eq!(max_bytes, Some(64 * 1024));
3186 assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
3187 }
3188 other => panic!("expected OutputTooLarge, got {other:?}"),
3189 }
3190 }
3191
3192 #[tokio::test]
3195 async fn pr_diff_within_override_reads_past_the_default() {
3196 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3197 let gh =
3198 GitHub::with_runner(ScriptedRunner::new().on(["gh", "pr", "diff"], Reply::ok(out)))
3199 .default_output_budget(OutputBudget::bytes(4)); assert!(matches!(
3201 err_reason(&gh.pr_diff(Path::new("/r"), 7).await),
3202 Some(ErrorReason::OutputTooLarge { .. })
3203 ));
3204 let files = gh
3205 .pr_diff_within(Path::new("/r"), 7, OutputBudget::unlimited())
3206 .await
3207 .expect("override reads the diff");
3208 assert_eq!(files.len(), 1);
3209 assert_eq!(files[0].path, std::path::Path::new("m"));
3210 }
3211
3212 #[tokio::test]
3221 async fn run_watch_bounds_output_without_failing_loud() {
3222 let flood = "watching run… job A: running\n".repeat(180_000);
3224 let run_json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
3225 "status":"completed","conclusion":"success","workflowName":"CI",
3226 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
3227 let gh = GitHub::with_runner(
3228 ScriptedRunner::new()
3229 .on(["gh", "run", "watch"], Reply::ok(&flood))
3230 .on(["gh", "run", "view"], Reply::ok(run_json)),
3231 );
3232 let run = gh
3235 .run_watch(Path::new("/r"), 42)
3236 .await
3237 .expect("a chatty watch is bounded, not failed loud");
3238 assert_eq!(run.database_id, 42);
3239 }
3240
3241 #[tokio::test]
3244 async fn pr_review_builds_action_args() {
3245 let rec = RecordingRunner::replying(Reply::ok(""));
3246 let gh = GitHub::with_runner(&rec);
3247 gh.pr_review(Path::new("/r"), 7, ReviewAction::approve())
3248 .await
3249 .expect("approve");
3250 gh.pr_review(
3251 Path::new("/r"),
3252 7,
3253 ReviewAction::request_changes("fix the parser"),
3254 )
3255 .await
3256 .expect("request changes");
3257 gh.pr_review(Path::new("/r"), 7, ReviewAction::comment("nice"))
3258 .await
3259 .expect("comment");
3260 let calls = rec.calls();
3261 assert_eq!(calls[0].args_str(), ["pr", "review", "7", "--approve"]);
3262 assert!(!calls[0].has_flag("--body"));
3263 assert_eq!(
3264 calls[1].args_str(),
3265 [
3266 "pr",
3267 "review",
3268 "7",
3269 "--request-changes",
3270 "--body",
3271 "fix the parser"
3272 ]
3273 );
3274 assert_eq!(
3275 calls[2].args_str(),
3276 ["pr", "review", "7", "--comment", "--body", "nice"]
3277 );
3278 }
3279
3280 #[tokio::test]
3283 async fn pr_review_approve_with_body() {
3284 let action = ReviewAction::approve().with_body("LGTM");
3285 assert_eq!(action.kind(), ReviewKind::Approve);
3286 assert_eq!(action.body(), Some("LGTM"));
3287
3288 let rec = RecordingRunner::replying(Reply::ok(""));
3289 let gh = GitHub::with_runner(&rec);
3290 gh.pr_review(Path::new("/r"), 7, action)
3291 .await
3292 .expect("approve with body");
3293 assert_eq!(
3294 rec.only_call().args_str(),
3295 ["pr", "review", "7", "--approve", "--body", "LGTM"]
3296 );
3297 }
3298
3299 #[tokio::test]
3300 async fn pr_comment_and_issue_create_return_urls() {
3301 let rec = RecordingRunner::replying(Reply::ok("https://gh/x\n"));
3302 let gh = GitHub::with_runner(&rec);
3303 assert_eq!(
3304 gh.pr_comment(Path::new("/r"), 7, "hello").await.unwrap(),
3305 "https://gh/x"
3306 );
3307 assert_eq!(
3308 gh.issue_create(Path::new("/r"), "T", "B").await.unwrap(),
3309 "https://gh/x"
3310 );
3311 let calls = rec.calls();
3312 assert_eq!(
3313 calls[0].args_str(),
3314 ["pr", "comment", "7", "--body", "hello"]
3315 );
3316 assert_eq!(
3317 calls[1].args_str(),
3318 ["issue", "create", "--title", "T", "--body", "B"]
3319 );
3320 }
3321
3322 #[tokio::test]
3326 async fn issue_close_reopen_and_comment_build_argv() {
3327 let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c1\n"));
3328 let gh = GitHub::with_runner(&rec);
3329
3330 gh.issue_close(Path::new("/r"), 7).await.expect("close");
3331 gh.issue_reopen(Path::new("/r"), 7).await.expect("reopen");
3332 assert_eq!(
3333 gh.issue_comment(Path::new("/r"), 7, "ping").await.unwrap(),
3334 "https://gh/i/7#c1"
3335 );
3336
3337 let calls = rec.calls();
3338 assert_eq!(calls[0].args_str(), ["issue", "close", "7"]);
3339 assert_eq!(calls[1].args_str(), ["issue", "reopen", "7"]);
3340 assert_eq!(
3341 calls[2].args_str(),
3342 ["issue", "comment", "7", "--body", "ping"]
3343 );
3344 }
3345
3346 #[tokio::test]
3350 async fn issue_comment_passes_leading_dash_body_verbatim() {
3351 let rec = RecordingRunner::replying(Reply::ok("https://gh/i/7#c2\n"));
3352 let gh = GitHub::with_runner(&rec);
3353 gh.issue_comment(Path::new("/r"), 7, "- a bullet")
3354 .await
3355 .expect("dash body");
3356 assert_eq!(
3357 rec.only_call().args_str(),
3358 ["issue", "comment", "7", "--body", "- a bullet"]
3359 );
3360 }
3361
3362 #[tokio::test]
3366 async fn pr_edit_emits_only_provided_fields() {
3367 let rec = RecordingRunner::replying(Reply::ok(""));
3368 let gh = GitHub::with_runner(&rec);
3369
3370 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("New title"))
3371 .await
3372 .expect("title-only edit");
3373 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().body("New body"))
3374 .await
3375 .expect("body-only edit");
3376 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title("T").body("B"))
3377 .await
3378 .expect("both-fields edit");
3379
3380 let calls = rec.calls();
3381 assert_eq!(
3382 calls[0].args_str(),
3383 ["pr", "edit", "7", "--title", "New title"]
3384 );
3385 assert_eq!(
3386 calls[1].args_str(),
3387 ["pr", "edit", "7", "--body", "New body"]
3388 );
3389 assert_eq!(
3390 calls[2].args_str(),
3391 ["pr", "edit", "7", "--title", "T", "--body", "B"]
3392 );
3393 }
3394
3395 #[tokio::test]
3400 async fn pr_edit_some_empty_string_clears_field() {
3401 let rec = RecordingRunner::replying(Reply::ok(""));
3402 let gh = GitHub::with_runner(&rec);
3403 gh.pr_edit(Path::new("/r"), 7, PrEdit::new().title(""))
3404 .await
3405 .expect("empty title");
3406 assert_eq!(
3407 rec.only_call().args_str(),
3408 ["pr", "edit", "7", "--title", ""]
3409 );
3410 }
3411
3412 #[tokio::test]
3413 async fn with_credentials_injects_gh_token_and_default_does_not() {
3414 let rec = RecordingRunner::replying(Reply::ok("[]"));
3417 let gh = GitHub::with_runner(&rec)
3418 .with_credentials(Arc::new(StaticCredential::token("tok-123")));
3419 gh.pr_list(Path::new("/r")).await.unwrap();
3420 let call = rec.only_call();
3421 let token = call
3422 .envs
3423 .iter()
3424 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3425 .and_then(|(_, v)| v.as_ref())
3426 .and_then(|v| v.to_str());
3427 assert_eq!(
3428 token,
3429 Some("tok-123"),
3430 "provider token injected as GH_TOKEN"
3431 );
3432 assert!(
3433 !call.args_str().iter().any(|a| a.contains("tok-123")),
3434 "secret must never appear in argv"
3435 );
3436
3437 let rec = RecordingRunner::replying(Reply::ok("[]"));
3439 let gh = GitHub::with_runner(&rec);
3440 gh.pr_list(Path::new("/r")).await.unwrap();
3441 assert!(
3442 !rec.only_call()
3443 .envs
3444 .iter()
3445 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
3446 "no provider → no token env (ambient gh auth)"
3447 );
3448 }
3449
3450 #[tokio::test]
3453 async fn with_token_convenience_injects_gh_token() {
3454 let rec = RecordingRunner::replying(Reply::ok("[]"));
3455 let gh = GitHub::with_runner(&rec).with_token("tok-conv");
3456 gh.pr_list(Path::new("/r")).await.unwrap();
3457 let call = rec.only_call();
3458 let token = call
3459 .envs
3460 .iter()
3461 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3462 .and_then(|(_, v)| v.as_ref())
3463 .and_then(|v| v.to_str());
3464 assert_eq!(token, Some("tok-conv"));
3465 }
3466
3467 #[tokio::test]
3471 async fn provider_returning_none_falls_back_to_ambient() {
3472 let rec = RecordingRunner::replying(Reply::ok("[]"));
3473 let gh = GitHub::with_runner(&rec).with_credentials(Arc::new(provider_fn(|_| Ok(None))));
3474 gh.pr_list(Path::new("/r")).await.unwrap();
3475 assert!(
3476 !rec.only_call()
3477 .envs
3478 .iter()
3479 .any(|(k, _)| k.to_str() == Some("GH_TOKEN")),
3480 "Ok(None) provider injects no token (ambient)"
3481 );
3482 }
3483
3484 #[tokio::test]
3485 async fn injected_token_overrides_ambient_default_env() {
3486 let rec = RecordingRunner::replying(Reply::ok("[]"));
3489 let gh = GitHub::with_runner(&rec)
3490 .default_env("GH_TOKEN", "ambient-token")
3491 .with_credentials(Arc::new(StaticCredential::token("provider-token")));
3492 gh.pr_list(Path::new("/r")).await.unwrap();
3493 let call = rec.only_call();
3494 let winner = call
3495 .envs
3496 .iter()
3497 .rev()
3498 .find(|(k, _)| k.to_str() == Some("GH_TOKEN"))
3499 .and_then(|(_, v)| v.as_ref())
3500 .and_then(|v| v.to_str());
3501 assert_eq!(winner, Some("provider-token"), "provider token wins");
3502 }
3503
3504 #[test]
3509 fn github_host_classifies_saas_and_enterprise() {
3510 let saas = GitHubHost::github_com();
3511 assert!(saas.is_github_com() && !saas.is_enterprise());
3512 assert_eq!(saas.as_str(), "github.com");
3513
3514 for h in ["github.com", "GitHub.com", "GITHUB.COM"] {
3515 let host = GitHubHost::new(h).unwrap();
3516 assert!(host.is_github_com(), "{h} should classify as SaaS");
3517 assert_eq!(host.as_str(), "github.com", "canonicalized to lower-case");
3518 }
3519
3520 let ghes = GitHubHost::new("GHE.Example.COM").unwrap();
3521 assert!(ghes.is_enterprise());
3522 assert_eq!(ghes.as_str(), "ghe.example.com");
3523 }
3524
3525 #[test]
3528 fn github_host_new_rejects_malformed_hosts() {
3529 for bad in [
3530 "",
3531 " ",
3532 "-evil",
3533 "has space",
3534 "https://github.com",
3535 "github.com/owner",
3536 "ghe.example.com:8443",
3537 "user@github.com",
3538 ".leading",
3539 "trailing.",
3540 ] {
3541 let err = GitHubHost::new(bad).unwrap_err();
3542 assert!(
3543 vcs_cli_support::is_invalid_input(&err),
3544 "{bad:?} should be rejected as invalid input, got {err:?}"
3545 );
3546 }
3547 }
3548
3549 #[test]
3552 fn github_host_from_remote_url_parses_and_classifies() {
3553 let cases = [
3554 ("https://github.com/o/r.git", "github.com", false),
3555 (
3556 "https://x-access-token:tok@ghe.example.com:8443/o/r",
3557 "ghe.example.com",
3558 true,
3559 ),
3560 ("http://ghe.internal.corp/o/r", "ghe.internal.corp", true),
3561 ("ssh://git@github.com/o/r", "github.com", false),
3562 ("ssh://git@ghe.example.com:22/o/r", "ghe.example.com", true),
3563 ("git@github.com:o/r.git", "github.com", false),
3564 ("git@ghe.example.com:o/r.git", "ghe.example.com", true),
3565 ];
3566 for (url, host, enterprise) in cases {
3567 let parsed =
3568 GitHubHost::from_remote_url(url).unwrap_or_else(|e| panic!("parse {url}: {e:?}"));
3569 assert_eq!(parsed.as_str(), host, "host for {url}");
3570 assert_eq!(parsed.is_enterprise(), enterprise, "class for {url}");
3571 }
3572 }
3573
3574 #[test]
3577 fn github_host_from_remote_url_rejects_ambiguous() {
3578 for url in [
3579 "",
3580 " ",
3581 "not-a-url",
3582 "https://",
3583 "ssh://",
3584 "git@internalhost:repo.git",
3585 "C:\\repo\\path",
3586 "https://[::1]:8443/x",
3587 ] {
3588 let err = GitHubHost::from_remote_url(url).unwrap_err();
3589 assert!(
3590 vcs_cli_support::is_invalid_input(&err),
3591 "{url:?} should be a diagnosable error, got {err:?}"
3592 );
3593 }
3594 }
3595
3596 #[tokio::test]
3599 async fn with_host_github_com_injects_gh_token() {
3600 let rec = RecordingRunner::replying(Reply::ok("[]"));
3601 let gh = GitHub::with_runner(&rec)
3602 .with_host(GitHubHost::github_com())
3603 .with_token("saas-tok");
3604 gh.pr_list(Path::new("/r")).await.unwrap();
3605 let call = rec.only_call();
3606 assert!(call.env_is("GH_TOKEN", "saas-tok"));
3607 assert!(
3608 !call.has_env("GH_ENTERPRISE_TOKEN"),
3609 "github.com must not touch the enterprise token env"
3610 );
3611 assert!(call.env_is("GH_HOST", "github.com"));
3612 assert!(!call.args_str().iter().any(|a| a.contains("saas-tok")));
3613 }
3614
3615 #[tokio::test]
3620 async fn with_host_enterprise_injects_enterprise_token_and_host() {
3621 let rec = RecordingRunner::replying(Reply::ok("[]"));
3622 let gh = GitHub::with_runner(&rec)
3623 .with_host(GitHubHost::new("ghe.example.com").unwrap())
3624 .with_token("ent-tok");
3625 gh.pr_list(Path::new("/r")).await.unwrap();
3626 let call = rec.only_call();
3627 assert!(call.env_is("GH_ENTERPRISE_TOKEN", "ent-tok"));
3628 assert!(
3629 !call.has_env("GH_TOKEN"),
3630 "enterprise token must not land in the github.com env"
3631 );
3632 assert!(call.env_is("GH_HOST", "ghe.example.com"));
3633 assert!(
3634 !call.args_str().iter().any(|a| a.contains("ent-tok")),
3635 "secret must never appear in argv"
3636 );
3637 }
3638
3639 #[tokio::test]
3642 async fn with_host_enterprise_without_credentials_is_ambient() {
3643 let rec = RecordingRunner::replying(Reply::ok("[]"));
3644 let gh = GitHub::with_runner(&rec).with_host(GitHubHost::new("ghe.corp.example").unwrap());
3645 gh.pr_list(Path::new("/r")).await.unwrap();
3646 let call = rec.only_call();
3647 assert!(!call.has_env("GH_ENTERPRISE_TOKEN"));
3648 assert!(!call.has_env("GH_TOKEN"));
3649 assert!(call.env_is("GH_HOST", "ghe.corp.example"));
3650 }
3651
3652 #[tokio::test]
3655 async fn multiple_hosts_inject_independently() {
3656 let rec_a = RecordingRunner::replying(Reply::ok("[]"));
3657 GitHub::with_runner(&rec_a)
3658 .with_host(GitHubHost::new("ghe.a.example").unwrap())
3659 .with_token("tok-a")
3660 .pr_list(Path::new("/r"))
3661 .await
3662 .unwrap();
3663
3664 let rec_b = RecordingRunner::replying(Reply::ok("[]"));
3665 GitHub::with_runner(&rec_b)
3666 .with_host(GitHubHost::new("ghe.b.example").unwrap())
3667 .with_token("tok-b")
3668 .pr_list(Path::new("/r"))
3669 .await
3670 .unwrap();
3671
3672 let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
3673 GitHub::with_runner(&rec_saas)
3674 .with_host(GitHubHost::github_com())
3675 .with_token("tok-saas")
3676 .pr_list(Path::new("/r"))
3677 .await
3678 .unwrap();
3679
3680 let ca = rec_a.only_call();
3681 assert!(ca.env_is("GH_ENTERPRISE_TOKEN", "tok-a") && ca.env_is("GH_HOST", "ghe.a.example"));
3682 assert!(
3683 !ca.args_str()
3684 .iter()
3685 .any(|s| s.contains("tok-b") || s.contains("tok-saas")),
3686 "host A must not carry another host's secret"
3687 );
3688
3689 let cb = rec_b.only_call();
3690 assert!(cb.env_is("GH_ENTERPRISE_TOKEN", "tok-b") && cb.env_is("GH_HOST", "ghe.b.example"));
3691
3692 let cs = rec_saas.only_call();
3693 assert!(cs.env_is("GH_TOKEN", "tok-saas") && cs.env_is("GH_HOST", "github.com"));
3694 assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
3695 }
3696
3697 #[tokio::test]
3703 async fn host_keyed_provider_injects_only_the_bound_hosts_token() {
3704 let provider: Arc<dyn CredentialProvider> =
3707 Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
3708 Ok(match r.host {
3709 Some("github.com") => Some(Credential::token("saas-secret")),
3710 Some("ghe.example.com") => Some(Credential::token("ent-secret")),
3711 _ => None,
3712 })
3713 }));
3714
3715 let rec_saas = RecordingRunner::replying(Reply::ok("[]"));
3717 GitHub::with_runner(&rec_saas)
3718 .with_host(GitHubHost::github_com())
3719 .with_credentials(Arc::clone(&provider))
3720 .pr_list(Path::new("/r"))
3721 .await
3722 .unwrap();
3723 let cs = rec_saas.only_call();
3724 assert!(cs.env_is("GH_TOKEN", "saas-secret"));
3725 assert!(!cs.has_env("GH_ENTERPRISE_TOKEN"));
3726 assert!(!cs.args_str().iter().any(|a| a.contains("saas-secret")));
3727
3728 let rec_ent = RecordingRunner::replying(Reply::ok("[]"));
3731 GitHub::with_runner(&rec_ent)
3732 .with_host(GitHubHost::new("ghe.example.com").unwrap())
3733 .with_credentials(Arc::clone(&provider))
3734 .pr_list(Path::new("/r"))
3735 .await
3736 .unwrap();
3737 let ce = rec_ent.only_call();
3738 assert!(ce.env_is("GH_ENTERPRISE_TOKEN", "ent-secret"));
3739 assert!(
3740 !ce.has_env("GH_TOKEN"),
3741 "the enterprise command must not carry the github.com token env"
3742 );
3743 assert!(!ce.args_str().iter().any(|a| a.contains("ent-secret")));
3744 }
3745
3746 #[tokio::test]
3750 async fn provider_none_defers_to_ambient_for_read_and_write() {
3751 let rec_read = RecordingRunner::replying(Reply::ok("[]"));
3752 GitHub::with_runner(&rec_read)
3753 .with_host(GitHubHost::github_com())
3754 .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
3755 .pr_list(Path::new("/r"))
3756 .await
3757 .unwrap();
3758 let cr = rec_read.only_call();
3759 assert!(
3760 !cr.has_env("GH_TOKEN") && !cr.has_env("GH_ENTERPRISE_TOKEN"),
3761 "read defers to ambient on Ok(None)"
3762 );
3763
3764 let rec_write = RecordingRunner::replying(Reply::ok(""));
3765 GitHub::with_runner(&rec_write)
3766 .with_host(GitHubHost::github_com())
3767 .with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))))
3768 .pr_merge(Path::new("/r"), 7, PrMerge::squash())
3769 .await
3770 .unwrap();
3771 let cw = rec_write.only_call();
3772 assert!(
3773 !cw.has_env("GH_TOKEN") && !cw.has_env("GH_ENTERPRISE_TOKEN"),
3774 "write defers to ambient on Ok(None)"
3775 );
3776 }
3777
3778 #[tokio::test]
3783 async fn provider_error_aborts_read_and_write_fail_closed() {
3784 fn boom() -> Arc<dyn CredentialProvider> {
3785 Arc::new(provider_fn(|_r: &CredentialRequest<'_>| {
3786 Err(Error::spawn(
3787 BINARY,
3788 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
3789 ))
3790 }))
3791 }
3792
3793 let rec_read = RecordingRunner::replying(Reply::ok("[]"));
3794 let read = GitHub::with_runner(&rec_read)
3795 .with_host(GitHubHost::github_com())
3796 .with_credentials(boom())
3797 .pr_list(Path::new("/r"))
3798 .await;
3799 assert!(read.is_err(), "a provider error must abort the read");
3800 assert!(
3801 rec_read.calls().is_empty(),
3802 "gh must not spawn when the provider errored (read)"
3803 );
3804
3805 let rec_write = RecordingRunner::replying(Reply::ok(""));
3806 let write = GitHub::with_runner(&rec_write)
3807 .with_host(GitHubHost::github_com())
3808 .with_credentials(boom())
3809 .pr_merge(Path::new("/r"), 7, PrMerge::squash())
3810 .await;
3811 assert!(write.is_err(), "a provider error must abort the write");
3812 assert!(
3813 rec_write.calls().is_empty(),
3814 "gh must not spawn when the provider errored (write)"
3815 );
3816 }
3817
3818 #[tokio::test]
3820 async fn auth_status_for_scopes_to_hostname() {
3821 let rec = RecordingRunner::replying(Reply::ok(""));
3822 let gh = GitHub::with_runner(&rec);
3823 let host = GitHubHost::new("ghe.example.com").unwrap();
3824 assert!(gh.auth_status_for(&host).await.unwrap());
3825 assert_eq!(
3826 rec.only_call().args_str(),
3827 ["auth", "status", "--hostname", "ghe.example.com"]
3828 );
3829 }
3830
3831 #[tokio::test]
3835 async fn auth_status_for_is_independent_of_other_host_sessions() {
3836 let runner = ScriptedRunner::new()
3837 .on(
3838 ["gh", "auth", "status", "--hostname", "broken.example.com"],
3839 Reply::fail(1, "not logged in to broken.example.com"),
3840 )
3841 .on(
3842 ["gh", "auth", "status", "--hostname", "good.example.com"],
3843 Reply::ok(""),
3844 );
3845 let gh = GitHub::with_runner(runner);
3846 assert!(
3847 gh.auth_status_for(&GitHubHost::new("good.example.com").unwrap())
3848 .await
3849 .unwrap(),
3850 "the healthy target host reads as authenticated"
3851 );
3852 assert!(
3853 !gh.auth_status_for(&GitHubHost::new("broken.example.com").unwrap())
3854 .await
3855 .unwrap(),
3856 "a broken host reads as not authenticated, independently"
3857 );
3858 }
3859
3860 #[tokio::test]
3863 async fn bound_view_auth_status_for_matches_client() {
3864 let rec = RecordingRunner::replying(Reply::ok(""));
3865 let gh = GitHub::with_runner(&rec);
3866 gh.at(Path::new("/repo"))
3867 .auth_status_for(&GitHubHost::github_com())
3868 .await
3869 .unwrap();
3870 let call = rec.only_call();
3871 assert_eq!(
3872 call.args_str(),
3873 ["auth", "status", "--hostname", "github.com"]
3874 );
3875 assert_eq!(call.cwd.as_deref(), None, "bare method binds no cwd");
3876 }
3877
3878 #[tokio::test]
3879 async fn pr_feedback_requests_reviews_and_comments() {
3880 let json = r#"{"reviews":[{"author":{"login":"a"},"state":"APPROVED",
3881 "body":"","submittedAt":""}],"comments":[]}"#;
3882 let rec =
3883 RecordingRunner::new(ScriptedRunner::new().on(["gh", "pr", "view"], Reply::ok(json)));
3884 let gh = GitHub::with_runner(&rec);
3885 let feedback = gh.pr_feedback(Path::new("."), 7).await.expect("feedback");
3886 assert_eq!(feedback.reviews[0].author, "a");
3887 assert!(feedback.comments.is_empty());
3888 assert_eq!(
3889 rec.only_call().args_str(),
3890 ["pr", "view", "7", "--json", "reviews,comments"]
3891 );
3892 }
3893
3894 #[tokio::test]
3896 async fn run_list_appends_branch_only_when_some() {
3897 let rec = RecordingRunner::replying(Reply::ok("[]"));
3898 let gh = GitHub::with_runner(&rec);
3899 gh.run_list(Path::new("/r"), 5, None).await.expect("list");
3900 gh.run_list(Path::new("/r"), 5, Some("main".into()))
3901 .await
3902 .expect("list");
3903 let calls = rec.calls();
3904 assert_eq!(
3905 calls[0].args_str(),
3906 ["run", "list", "--limit", "5", "--json", RUN_FIELDS]
3907 );
3908 assert_eq!(
3909 calls[1].args_str(),
3910 [
3911 "run", "list", "--limit", "5", "--branch", "main", "--json", RUN_FIELDS
3912 ]
3913 );
3914 }
3915
3916 #[tokio::test]
3917 async fn workflow_list_builds_default_and_disabled_inclusive_argv() {
3918 let rec = RecordingRunner::replying(Reply::ok("[]"));
3919 let gh = GitHub::with_runner(&rec);
3920 gh.workflow_list(Path::new("/r")).await.expect("list");
3921 gh.at(Path::new("/r"))
3922 .workflow_list_with(WorkflowList::new().all().limit(75))
3923 .await
3924 .expect("list all");
3925
3926 let calls = rec.calls();
3927 assert_eq!(
3928 calls[0].args_str(),
3929 [
3930 "workflow",
3931 "list",
3932 "--limit",
3933 "50",
3934 "--json",
3935 WORKFLOW_FIELDS
3936 ]
3937 );
3938 assert_eq!(
3939 calls[1].args_str(),
3940 [
3941 "workflow",
3942 "list",
3943 "--limit",
3944 "75",
3945 "--all",
3946 "--json",
3947 WORKFLOW_FIELDS
3948 ]
3949 );
3950 assert_eq!(calls[1].cwd.as_deref(), Some(Path::new("/r")));
3951 }
3952
3953 #[tokio::test]
3954 async fn workflow_list_rejects_zero_limit_before_spawn() {
3955 let rec = RecordingRunner::replying(Reply::ok("[]"));
3956 let err = GitHub::with_runner(&rec)
3957 .workflow_list_with(Path::new("/r"), WorkflowList::new().limit(0))
3958 .await
3959 .unwrap_err();
3960 assert!(vcs_cli_support::is_invalid_input(&err));
3961 assert!(rec.calls().is_empty());
3962 }
3963
3964 #[tokio::test]
3965 async fn workflow_view_resolves_id_name_filename_and_path_from_json_inventory() {
3966 let json = r#"[
3967 {"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
3968 {"id":18,"name":"Deploy","path":".github/workflows/deploy.yaml","state":"disabled_manually"}
3969 ]"#;
3970 let rec = RecordingRunner::new(
3971 ScriptedRunner::new().on(["gh", "workflow", "list"], Reply::ok(json)),
3972 );
3973 let gh = GitHub::with_runner(&rec);
3974
3975 assert_eq!(
3976 gh.workflow_view(Path::new("/r"), "17").await.unwrap().id,
3977 17
3978 );
3979 assert_eq!(
3980 gh.workflow_view(Path::new("/r"), "ci").await.unwrap().id,
3981 17
3982 );
3983 assert_eq!(
3984 gh.workflow_view(Path::new("/r"), "deploy.yaml")
3985 .await
3986 .unwrap()
3987 .id,
3988 18
3989 );
3990 assert_eq!(
3991 gh.workflow_view(Path::new("/r"), ".github/workflows/ci.yml")
3992 .await
3993 .unwrap()
3994 .id,
3995 17
3996 );
3997
3998 for call in rec.calls() {
3999 assert_eq!(
4000 call.args_str(),
4001 [
4002 "workflow",
4003 "list",
4004 "--limit",
4005 WORKFLOW_VIEW_LOOKUP_LIMIT.to_string().as_str(),
4006 "--all",
4007 "--json",
4008 WORKFLOW_FIELDS
4009 ]
4010 );
4011 }
4012 }
4013
4014 #[tokio::test]
4015 async fn workflow_view_reports_empty_missing_and_ambiguous_selectors() {
4016 let rec = RecordingRunner::replying(Reply::ok(
4017 r#"[
4018 {"id":17,"name":"CI","path":".github/workflows/ci.yml","state":"active"},
4019 {"id":18,"name":"ci","path":".github/workflows/other.yml","state":"active"}
4020 ]"#,
4021 ));
4022 let gh = GitHub::with_runner(&rec);
4023
4024 let empty = gh.workflow_view(Path::new("/r"), "").await.unwrap_err();
4025 assert!(vcs_cli_support::is_invalid_input(&empty));
4026 assert!(rec.calls().is_empty(), "empty selector must not spawn");
4027
4028 for selector in ["missing", "CI"] {
4029 assert!(matches!(
4030 gh.workflow_view(Path::new("/r"), selector)
4031 .await
4032 .unwrap_err()
4033 .reason(),
4034 ErrorReason::Parse { .. }
4035 ));
4036 }
4037 assert_eq!(rec.calls().len(), 2);
4038 }
4039
4040 #[tokio::test]
4044 async fn run_watch_then_views_final_state() {
4045 let json = r#"{"databaseId":42,"name":"CI","displayTitle":"t",
4046 "status":"completed","conclusion":"failure","workflowName":"CI",
4047 "headBranch":"main","event":"push","url":"u","createdAt":"c"}"#;
4048 let rec = RecordingRunner::new(
4049 ScriptedRunner::new()
4050 .on(["gh", "run", "watch"], Reply::ok("✓ run completed"))
4051 .on(["gh", "run", "view"], Reply::ok(json)),
4052 );
4053 let gh = GitHub::with_runner(&rec);
4054 let run = gh.run_watch(Path::new("."), 42).await.expect("run_watch");
4055 assert_eq!(run.conclusion, "failure");
4056 let calls = rec.calls();
4057 assert_eq!(calls.len(), 2);
4058 assert_eq!(calls[0].args_str(), ["run", "watch", "42"]);
4059 assert_eq!(
4060 calls[1].args_str(),
4061 ["run", "view", "42", "--json", RUN_FIELDS]
4062 );
4063 }
4064
4065 #[tokio::test]
4069 async fn run_watch_surfaces_timeout_and_watch_errors() {
4070 let rec = RecordingRunner::new(
4071 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::timeout()),
4072 );
4073 let gh = GitHub::with_runner(&rec);
4074 assert!(matches!(
4075 gh.run_watch(Path::new("."), 42).await.unwrap_err().reason(),
4076 ErrorReason::Timeout { .. }
4077 ));
4078 assert_eq!(rec.calls().len(), 1, "no view after a timed-out watch");
4079
4080 let gh = GitHub::with_runner(
4081 ScriptedRunner::new().on(["gh", "run", "watch"], Reply::fail(1, "no such run")),
4082 );
4083 assert!(matches!(
4084 gh.run_watch(Path::new("."), 42).await.unwrap_err().reason(),
4085 ErrorReason::Exit { .. }
4086 ));
4087 }
4088
4089 #[tokio::test(start_paused = true)]
4092 async fn run_watch_times_out_after_output_inactivity() {
4093 let gh =
4094 GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()));
4095 match gh.run_watch(Path::new("."), 42).await.unwrap_err().reason() {
4096 ErrorReason::Timeout {
4097 timeout,
4098 inactivity,
4099 ..
4100 } => {
4101 assert_eq!(*timeout, RUN_WATCH_INACTIVITY_TIMEOUT);
4102 assert!(*inactivity);
4103 }
4104 other => panic!("expected output-inactivity timeout, got {other:?}"),
4105 }
4106 }
4107
4108 #[tokio::test(start_paused = true)]
4113 async fn run_watch_cancels_via_client_default_token() {
4114 use processkit::CancellationToken;
4115 let token = CancellationToken::new();
4116 let gh =
4117 GitHub::with_runner(ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()))
4118 .default_cancel_on(token.clone());
4119 let call = gh.run_watch(Path::new("."), 42);
4120 tokio::pin!(call);
4121 assert!(
4122 tokio::time::timeout(Duration::from_secs(1), &mut call)
4123 .await
4124 .is_err(),
4125 "run_watch must remain pending until cancellation or its inactivity deadline"
4126 );
4127 token.cancel();
4128 match call.await.map_err(Error::into_reason) {
4129 Err(ErrorReason::Cancelled { program }) => assert_eq!(program, "gh"),
4130 other => panic!("expected ErrorReason::Cancelled, got {other:?}"),
4131 }
4132 }
4133
4134 #[tokio::test]
4140 async fn workflow_dispatch_builds_argv_with_ref_and_inputs() {
4141 let rec = RecordingRunner::replying(Reply::ok(""));
4142 let gh = GitHub::with_runner(&rec);
4143 gh.workflow_dispatch(
4144 Path::new("/repo"),
4145 WorkflowDispatch::new("release.yml")
4146 .git_ref("main")
4147 .field("name", "scully")
4148 .field("greeting", "hello"),
4149 )
4150 .await
4151 .expect("workflow_dispatch");
4152 let call = rec.only_call();
4153 assert_eq!(call.cwd.as_deref(), Some(Path::new("/repo")));
4154 assert_eq!(
4155 call.args_str(),
4156 [
4157 "workflow",
4158 "run",
4159 "release.yml",
4160 "--ref",
4161 "main",
4162 "--raw-field",
4163 "name=scully",
4164 "--raw-field",
4165 "greeting=hello",
4166 ]
4167 );
4168 }
4169
4170 #[tokio::test]
4175 async fn workflow_dispatch_omits_unset_ref_and_allows_dash_value() {
4176 let rec = RecordingRunner::replying(Reply::ok(""));
4177 let gh = GitHub::with_runner(&rec);
4178 gh.workflow_dispatch(Path::new("/r"), WorkflowDispatch::new("ci.yml"))
4179 .await
4180 .expect("workflow_dispatch");
4181 assert_eq!(rec.calls()[0].args_str(), ["workflow", "run", "ci.yml"]);
4182
4183 let rec = RecordingRunner::replying(Reply::ok(""));
4185 let gh = GitHub::with_runner(&rec);
4186 gh.workflow_dispatch(
4187 Path::new("/r"),
4188 WorkflowDispatch::new("ci.yml").field("flag", "-x"),
4189 )
4190 .await
4191 .expect("workflow_dispatch");
4192 assert_eq!(
4193 rec.only_call().args_str(),
4194 ["workflow", "run", "ci.yml", "--raw-field", "flag=-x"]
4195 );
4196 }
4197
4198 #[tokio::test]
4202 async fn workflow_dispatch_rejects_flag_like_workflow() {
4203 let rec = RecordingRunner::replying(Reply::ok(""));
4204 let gh = GitHub::with_runner(&rec);
4205 assert!(
4206 gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("-evil"))
4207 .await
4208 .is_err()
4209 );
4210 assert!(
4211 gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new(""))
4212 .await
4213 .is_err()
4214 );
4215 assert!(rec.calls().is_empty(), "nothing may spawn");
4216 }
4217
4218 #[tokio::test]
4222 async fn workflow_dispatch_rejects_invalid_input_keys_before_spawning() {
4223 let gh = GitHub::with_runner(ScriptedRunner::new());
4224 for key in ["", "a=b", "\0"] {
4225 let err = gh
4226 .workflow_dispatch(
4227 Path::new("."),
4228 WorkflowDispatch::new("ci.yml").field(key, "value"),
4229 )
4230 .await
4231 .unwrap_err();
4232 assert!(
4233 vcs_cli_support::is_invalid_input(&err),
4234 "{key:?} should be rejected before spawning, got {err:?}"
4235 );
4236 }
4237 }
4238
4239 #[tokio::test]
4242 async fn run_rerun_builds_argv_for_each_scope() {
4243 let rec = RecordingRunner::replying(Reply::ok(""));
4244 let gh = GitHub::with_runner(&rec);
4245 gh.run_rerun(Path::new("/r"), 42, RerunScope::All)
4246 .await
4247 .expect("rerun all");
4248 gh.run_rerun(Path::new("/r"), 42, RerunScope::FailedOnly)
4249 .await
4250 .expect("rerun failed");
4251 let calls = rec.calls();
4252 assert_eq!(calls[0].args_str(), ["run", "rerun", "42"]);
4253 assert!(!calls[0].has_flag("--failed"), "All reruns the whole run");
4254 assert_eq!(calls[1].args_str(), ["run", "rerun", "42", "--failed"]);
4255 }
4256
4257 #[tokio::test]
4259 async fn run_cancel_builds_argv() {
4260 let rec = RecordingRunner::replying(Reply::ok(""));
4261 let gh = GitHub::with_runner(&rec);
4262 gh.run_cancel(Path::new("/r"), 42).await.expect("cancel");
4263 assert_eq!(rec.only_call().args_str(), ["run", "cancel", "42"]);
4264 }
4265
4266 #[tokio::test]
4269 async fn run_control_surfaces_gh_exit_errors() {
4270 let gh = GitHub::with_runner(ScriptedRunner::new().on(
4271 ["gh", "run", "cancel"],
4272 Reply::fail(1, "Cannot cancel a workflow run that is completed"),
4273 ));
4274 assert!(matches!(
4275 gh.run_cancel(Path::new("."), 42)
4276 .await
4277 .unwrap_err()
4278 .reason(),
4279 ErrorReason::Exit { .. }
4280 ));
4281
4282 let gh = GitHub::with_runner(ScriptedRunner::new().on(
4283 ["gh", "workflow", "run"],
4284 Reply::fail(
4285 1,
4286 "HTTP 404: workflow x.yml not found on the default branch",
4287 ),
4288 ));
4289 assert!(matches!(
4290 gh.workflow_dispatch(Path::new("."), WorkflowDispatch::new("x.yml"))
4291 .await
4292 .unwrap_err()
4293 .reason(),
4294 ErrorReason::Exit { .. }
4295 ));
4296 }
4297
4298 #[tokio::test]
4304 async fn release_view_requests_view_fields() {
4305 let cassette = RecordReplayRunner::replay(cassette_path("release_round_trip.json"))
4306 .expect("load recorded release cassette");
4307 let rec = RecordingRunner::new(cassette);
4308 let gh = GitHub::with_runner(&rec);
4309 let releases = gh.release_list(Path::new(".")).await.expect("release_list");
4310 let tag = releases
4311 .first()
4312 .expect("recorded cassette has a release")
4313 .tag_name
4314 .clone();
4315 let release = gh
4316 .release_view(Path::new("."), &tag)
4317 .await
4318 .expect("release_view");
4319 assert_eq!(release.tag_name, tag);
4320 assert!(
4321 release.body.as_deref().is_some_and(|b| !b.is_empty()),
4322 "release notes were recorded"
4323 );
4324 assert!(release.url.as_deref().is_some_and(|u| !u.is_empty()));
4325 let calls = rec.calls();
4326 assert_eq!(calls.len(), 2);
4327 assert_eq!(
4328 calls[1].args_str(),
4329 [
4330 "release",
4331 "view",
4332 tag.as_str(),
4333 "--json",
4334 RELEASE_VIEW_FIELDS
4335 ]
4336 );
4337 }
4338
4339 #[tokio::test]
4343 async fn run_list_and_view_replay_recorded_cassette() {
4344 let cassette = RecordReplayRunner::replay(cassette_path("run_round_trip.json"))
4345 .expect("load recorded run cassette");
4346 let rec = RecordingRunner::new(cassette);
4347 let gh = GitHub::with_runner(&rec);
4348 let runs = gh
4349 .run_list(Path::new("."), 3, None)
4350 .await
4351 .expect("run_list");
4352 let first = runs.first().expect("recorded cassette has runs");
4353 assert!(first.database_id > 0);
4354 assert!(!first.workflow_name.is_empty());
4355 let run = gh
4356 .run_view(Path::new("."), first.database_id)
4357 .await
4358 .expect("run_view");
4359 assert_eq!(run.database_id, first.database_id);
4360 assert_eq!(run.workflow_name, first.workflow_name);
4361 let calls = rec.calls();
4362 assert_eq!(calls.len(), 2);
4363 assert_eq!(
4364 calls[0].args_str(),
4365 ["run", "list", "--limit", "3", "--json", RUN_FIELDS]
4366 );
4367 assert_eq!(
4368 calls[1].args_str(),
4369 [
4370 "run",
4371 "view",
4372 first.database_id.to_string().as_str(),
4373 "--json",
4374 RUN_FIELDS
4375 ]
4376 );
4377 }
4378
4379 #[tokio::test]
4382 async fn repo_view_parses_scripted_json() {
4383 let json = r#"{"name":"r","owner":{"login":"o"},"description":"d","url":"u","isPrivate":false,"defaultBranchRef":{"name":"main"}}"#;
4384 let gh =
4385 GitHub::with_runner(ScriptedRunner::new().on(["gh", "repo", "view"], Reply::ok(json)));
4386 let repo = gh.repo_view(Path::new(".")).await.expect("repo_view");
4387 assert_eq!(repo.owner, "o");
4388 assert_eq!(repo.default_branch, "main");
4389 assert!(!repo.is_private);
4390 }
4391
4392 #[cfg(feature = "mock")]
4393 #[tokio::test]
4394 async fn consumer_mocks_the_interface() {
4395 let mut mock = MockGitHubApi::new();
4396 mock.expect_auth_status().returning(|| Ok(true));
4397 assert!(mock.auth_status().await.unwrap());
4398 }
4399}
4400
4401#[cfg(test)]
4402mod label_tests {
4403 use super::*;
4404 use processkit::testing::{RecordingRunner, Reply};
4405
4406 #[tokio::test]
4407 async fn label_create_and_mutation_argv_are_exact_and_flag_values() {
4408 let rec = RecordingRunner::replying(Reply::ok("https://example.test/1\n"));
4409 let gh = GitHub::with_runner(&rec);
4410 let labels = vec!["-urgent".to_string(), "help wanted".to_string()];
4411
4412 gh.pr_create(
4413 Path::new("/repo"),
4414 PrCreate::new("T", "B").labels(labels.clone()),
4415 )
4416 .await
4417 .unwrap();
4418 gh.issue_create_with(
4419 Path::new("/repo"),
4420 IssueCreate::new("I", "D").labels(labels.clone()),
4421 )
4422 .await
4423 .unwrap();
4424 gh.at(Path::new("/repo"))
4425 .pr_add_labels(7, &labels)
4426 .await
4427 .unwrap();
4428 gh.pr_remove_labels(Path::new("/repo"), 7, &labels)
4429 .await
4430 .unwrap();
4431 gh.issue_add_labels(Path::new("/repo"), 9, &labels)
4432 .await
4433 .unwrap();
4434 gh.issue_remove_labels(Path::new("/repo"), 9, &labels)
4435 .await
4436 .unwrap();
4437
4438 let calls = rec.calls();
4439 assert_eq!(
4440 calls[0].args_str(),
4441 [
4442 "pr",
4443 "create",
4444 "--title",
4445 "T",
4446 "--body",
4447 "B",
4448 "--label",
4449 "-urgent",
4450 "--label",
4451 "help wanted"
4452 ]
4453 );
4454 assert_eq!(
4455 calls[1].args_str(),
4456 [
4457 "issue",
4458 "create",
4459 "--title",
4460 "I",
4461 "--body",
4462 "D",
4463 "--label",
4464 "-urgent",
4465 "--label",
4466 "help wanted"
4467 ]
4468 );
4469 assert_eq!(
4470 calls[2].args_str(),
4471 [
4472 "pr",
4473 "edit",
4474 "7",
4475 "--add-label",
4476 "-urgent",
4477 "--add-label",
4478 "help wanted"
4479 ]
4480 );
4481 assert_eq!(calls[2].cwd.as_deref(), Some(Path::new("/repo")));
4482 assert_eq!(
4483 calls[3].args_str(),
4484 [
4485 "pr",
4486 "edit",
4487 "7",
4488 "--remove-label",
4489 "-urgent",
4490 "--remove-label",
4491 "help wanted"
4492 ]
4493 );
4494 assert_eq!(
4495 calls[4].args_str(),
4496 [
4497 "issue",
4498 "edit",
4499 "9",
4500 "--add-label",
4501 "-urgent",
4502 "--add-label",
4503 "help wanted"
4504 ]
4505 );
4506 assert_eq!(
4507 calls[5].args_str(),
4508 [
4509 "issue",
4510 "edit",
4511 "9",
4512 "--remove-label",
4513 "-urgent",
4514 "--remove-label",
4515 "help wanted"
4516 ]
4517 );
4518 }
4519
4520 #[tokio::test]
4521 async fn empty_label_mutation_is_rejected_before_spawn() {
4522 let rec = RecordingRunner::replying(Reply::ok(""));
4523 let err = GitHub::with_runner(&rec)
4524 .pr_add_labels(Path::new("/repo"), 1, &[])
4525 .await
4526 .unwrap_err();
4527 assert!(vcs_cli_support::is_invalid_input(&err));
4528 assert!(rec.calls().is_empty());
4529 }
4530}
4531
4532#[doc = include_str!("../docs/github.md")]
4534#[allow(rustdoc::broken_intra_doc_links)]
4535pub mod guide {}