1use crate::cmd::CommandErrorContext;
11use crate::github::http::GhHttp;
12use regex::Regex;
13use std::path::Path;
14use std::sync::LazyLock;
15
16use tokio::process::Command;
17
18pub(crate) fn format_task_ref(task_id: &str) -> String {
24 if task_id.starts_with("internal:") {
25 task_id.replace(':', "-")
26 } else {
27 format!("#{task_id}")
28 }
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum PrCreateError {
34 #[error("GitHub API error: {0}")]
35 ApiError(#[from] anyhow::Error),
36}
37
38pub type PrCreateResult<T> = Result<T, PrCreateError>;
40
41pub async fn has_commits_ahead(dir: &Path, default_branch: &str) -> bool {
43 let output = Command::new("git")
44 .args([
45 "rev-list",
46 "--count",
47 &format!("origin/{default_branch}..HEAD"),
48 ])
49 .current_dir(dir)
50 .output()
51 .await;
52 match output {
53 Ok(o) if o.status.success() => {
54 let count: u32 = String::from_utf8_lossy(&o.stdout)
55 .trim()
56 .parse()
57 .unwrap_or(0);
58 count > 0
59 }
60 _ => {
61 tracing::debug!(
62 default_branch,
63 "git rev-list failed for compare ref, assuming commits ahead"
64 );
65 true
69 }
70 }
71}
72
73pub async fn has_changes(dir: &Path) -> bool {
75 let diff = Command::new("git")
77 .args(["diff", "--quiet"])
78 .current_dir(dir)
79 .status()
80 .await;
81
82 let cached = Command::new("git")
83 .args(["diff", "--cached", "--quiet"])
84 .current_dir(dir)
85 .status()
86 .await;
87
88 let untracked = Command::new("git")
89 .args(["ls-files", "--others", "--exclude-standard"])
90 .current_dir(dir)
91 .output_with_context()
92 .await;
93
94 let has_diff = diff.map(|s| !s.success()).unwrap_or(false);
95 let has_cached = cached.map(|s| !s.success()).unwrap_or(false);
96 let has_untracked = untracked
97 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
98 .unwrap_or(false);
99
100 has_diff || has_cached || has_untracked
101}
102
103pub(crate) fn find_stash_ref_by_hash(stash_list: &str, stash_hash: &str) -> Option<String> {
104 stash_list.lines().find_map(|line| {
105 let mut parts = line.split_whitespace();
106 match (parts.next(), parts.next()) {
107 (Some(hash), Some(reference)) if hash == stash_hash => Some(reference.to_string()),
108 _ => None,
109 }
110 })
111}
112
113pub(crate) struct StashRebaseOpts {
115 pub disable_gpg_signing: bool,
117 pub abort_on_failure: bool,
121}
122
123impl Default for StashRebaseOpts {
124 fn default() -> Self {
125 Self {
126 disable_gpg_signing: false,
127 abort_on_failure: true,
128 }
129 }
130}
131
132#[derive(Debug)]
134pub(crate) enum RebaseOutcome {
135 Succeeded,
137 Failed(String),
141 Skipped(String),
144}
145
146pub(crate) async fn stash_rebase_restore(
163 dir: &Path,
164 target_ref: &str,
165 opts: StashRebaseOpts,
166) -> anyhow::Result<RebaseOutcome> {
167 let stash_ref: Option<String> = if has_changes(dir).await {
168 let stash_out = Command::new("git")
169 .args(["stash", "push", "--include-untracked", "-m", "orch-rebase"])
170 .current_dir(dir)
171 .output_with_context()
172 .await;
173
174 match stash_out {
175 Ok(o) if o.status.success() => {
176 let stdout_preview = String::from_utf8_lossy(&o.stdout)
177 .lines()
178 .next()
179 .unwrap_or("")
180 .to_string();
181 tracing::debug!(
182 dir = %dir.display(),
183 stdout = %stdout_preview,
184 "git stash push succeeded before rebase"
185 );
186
187 let ref_out = Command::new("git")
191 .args(["rev-parse", "refs/stash@{0}"])
192 .current_dir(dir)
193 .output_with_context()
194 .await;
195 let stash_hash = ref_out
196 .ok()
197 .filter(|o| o.status.success())
198 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
199 .filter(|s| !s.is_empty());
200
201 if stash_hash.is_none() {
202 tracing::warn!(
205 dir = %dir.display(),
206 "git stash succeeded but rev-parse failed — popping stash and skipping rebase"
207 );
208 let _ = Command::new("git")
209 .args(["stash", "pop"])
210 .current_dir(dir)
211 .output_with_context()
212 .await;
213 return Ok(RebaseOutcome::Skipped(
214 "stash OID capture failed".to_string(),
215 ));
216 }
217 stash_hash
218 }
219 Ok(o) => {
220 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
221 tracing::warn!(
222 dir = %dir.display(),
223 code = ?o.status.code(),
224 stderr = %stderr,
225 "git stash push failed — skipping rebase to preserve dirty state"
226 );
227 return Ok(RebaseOutcome::Skipped(format!(
228 "stash push failed: {stderr}"
229 )));
230 }
231 Err(e) => {
232 tracing::warn!(
233 dir = %dir.display(),
234 error = %e,
235 "failed to run git stash push — skipping rebase to preserve dirty state"
236 );
237 return Ok(RebaseOutcome::Skipped(format!("stash push error: {e}")));
238 }
239 }
240 } else {
241 None
242 };
243
244 let mut rebase_args: Vec<&str> = Vec::new();
245 if opts.disable_gpg_signing {
246 rebase_args.extend(["-c", "commit.gpgsign=false"]);
247 }
248 rebase_args.extend(["rebase", target_ref]);
249
250 let output = Command::new("git")
251 .args(&rebase_args)
252 .current_dir(dir)
253 .output_with_context()
254 .await;
255
256 match output {
257 Ok(o) if o.status.success() => {
258 tracing::debug!(dir = %dir.display(), target_ref, "rebase succeeded");
259 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
260 Ok(RebaseOutcome::Succeeded)
261 }
262 Ok(o) => {
263 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
264 tracing::warn!(dir = %dir.display(), target_ref, err = %stderr, "rebase failed");
265 if opts.abort_on_failure {
266 let abort = Command::new("git")
267 .args(["rebase", "--abort"])
268 .current_dir(dir)
269 .output_with_context()
270 .await;
271 match abort {
272 Err(e) => {
273 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
274 }
275 Ok(o) if !o.status.success() => {
276 let stderr = String::from_utf8_lossy(&o.stderr);
277 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
278 }
279 _ => {}
280 }
281 }
282 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
283 Ok(RebaseOutcome::Failed(stderr))
284 }
285 Err(e) => {
286 if opts.abort_on_failure {
287 let abort = Command::new("git")
288 .args(["rebase", "--abort"])
289 .current_dir(dir)
290 .output_with_context()
291 .await;
292 match abort {
293 Err(e) => {
294 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
295 }
296 Ok(o) if !o.status.success() => {
297 let stderr = String::from_utf8_lossy(&o.stderr);
298 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
299 }
300 _ => {}
301 }
302 }
303 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
304 Err(e)
305 }
306 }
307}
308
309async fn restore_stash_by_hash(dir: &Path, stash_hash: Option<&str>) {
313 let Some(stash_hash) = stash_hash else {
314 return;
315 };
316
317 let apply = Command::new("git")
318 .args(["stash", "apply", stash_hash])
319 .current_dir(dir)
320 .output_with_context()
321 .await;
322 if apply.map(|o| o.status.success()).unwrap_or(false) {
323 let list = Command::new("git")
324 .args(["stash", "list", "--format=%H %gd"])
325 .current_dir(dir)
326 .output_with_context()
327 .await;
328 if let Ok(list_out) = list {
329 if list_out.status.success() {
330 let list_str = String::from_utf8_lossy(&list_out.stdout);
331 if let Some(stash_ref) = find_stash_ref_by_hash(&list_str, stash_hash) {
332 match Command::new("git")
333 .args(["stash", "drop", &stash_ref])
334 .current_dir(dir)
335 .output_with_context()
336 .await
337 {
338 Ok(drop_out) if !drop_out.status.success() => {
339 let stderr = String::from_utf8_lossy(&drop_out.stderr);
340 tracing::warn!(
341 dir = %dir.display(),
342 stash_ref = %stash_ref,
343 err = %stderr,
344 "stash drop failed after apply — entry may remain on stack"
345 );
346 }
347 Err(e) => {
348 tracing::warn!(
349 dir = %dir.display(),
350 stash_ref = %stash_ref,
351 error = %e,
352 "stash drop command failed after apply — entry may remain on stack"
353 );
354 }
355 _ => {}
356 }
357 } else {
358 tracing::warn!(
359 dir = %dir.display(),
360 stash = %stash_hash,
361 "stash applied but could not find ref by hash — stash entry may be orphaned on stack"
362 );
363 }
364 }
365 }
366 } else {
367 tracing::warn!(
368 dir = %dir.display(),
369 stash = %stash_hash,
370 "stash apply failed after rebase — stash preserved for manual recovery"
371 );
372 }
373}
374
375fn git_identity(agent: &str) -> (String, String) {
377 let name = crate::config::get("git.name").unwrap_or_else(|_| format!("{agent}[bot]"));
378 let email = crate::config::get("git.email")
379 .unwrap_or_else(|_| format!("{agent}[bot]@users.noreply.github.com"));
380 (name, email)
381}
382
383pub async fn auto_commit(
385 dir: &Path,
386 task_id: &str,
387 title: &str,
388 agent: &str,
389 attempt: u32,
390) -> anyhow::Result<bool> {
391 if !has_changes(dir).await {
393 return Ok(false);
394 }
395
396 tracing::info!(task_id, "auto-committing uncommitted changes");
397
398 let task_ref = format_task_ref(task_id);
399 let commit_msg = format!("{title}\n\nTask {task_ref}\nAgent: {agent}\nAttempt: {attempt}");
400
401 let add = Command::new("git")
403 .args(["add", "-A"])
404 .current_dir(dir)
405 .output_with_context()
406 .await?;
407
408 if !add.status.success() {
409 tracing::warn!(task_id, "git add -A failed");
410 return Ok(false);
411 }
412
413 let (git_name, git_email) = git_identity(agent);
415 let commit = Command::new("git")
416 .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
418 .env("GIT_AUTHOR_NAME", &git_name)
419 .env("GIT_COMMITTER_NAME", &git_name)
420 .env("GIT_AUTHOR_EMAIL", &git_email)
421 .env("GIT_COMMITTER_EMAIL", &git_email)
422 .current_dir(dir)
423 .output_with_context()
424 .await?;
425
426 if !commit.status.success() {
427 let stderr = String::from_utf8_lossy(&commit.stderr);
428 tracing::warn!(task_id, err = %stderr, "git commit failed");
429 let restore = Command::new("git")
432 .args(["restore", "--staged", "."])
433 .current_dir(dir)
434 .output_with_context()
435 .await;
436 match restore {
437 Ok(o) if o.status.success() => {}
438 Ok(o) => {
439 let restore_stderr = String::from_utf8_lossy(&o.stderr).to_string();
440 tracing::warn!(
441 task_id,
442 stderr = %restore_stderr,
443 "git restore --staged failed after commit failure; falling back to git reset HEAD -- ."
444 );
445 let reset = Command::new("git")
446 .args(["reset", "HEAD", "--", "."])
447 .current_dir(dir)
448 .output_with_context()
449 .await?;
450 if !reset.status.success() {
451 anyhow::bail!(
452 "git commit failed and cleanup failed (git restore --staged + git reset HEAD -- .): commit stderr: {}; restore stderr: {}; reset stderr: {}",
453 stderr.trim(),
454 restore_stderr.trim(),
455 String::from_utf8_lossy(&reset.stderr).trim()
456 );
457 }
458 }
459 Err(e) => {
460 tracing::warn!(
461 task_id,
462 error = %e,
463 "git restore --staged command failed after commit failure; falling back to git reset HEAD -- ."
464 );
465 let reset = Command::new("git")
466 .args(["reset", "HEAD", "--", "."])
467 .current_dir(dir)
468 .output_with_context()
469 .await?;
470 if !reset.status.success() {
471 anyhow::bail!(
472 "git commit failed and cleanup failed (git restore --staged command error + git reset HEAD -- .): commit stderr: {}; restore error: {}; reset stderr: {}",
473 stderr.trim(),
474 e,
475 String::from_utf8_lossy(&reset.stderr).trim()
476 );
477 }
478 }
479 }
480 return Ok(false);
481 }
482
483 tracing::info!(task_id, "auto-commit succeeded");
484 Ok(true)
485}
486
487const MAX_REBASE_COMMITS: usize = 50;
495
496pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
506 let origin_branch = format!("origin/{branch}");
507
508 let fetch = tokio::time::timeout(
511 std::time::Duration::from_secs(120),
512 Command::new("git")
513 .args(["fetch", "origin", branch])
514 .kill_on_drop(true)
515 .current_dir(dir)
516 .output(),
517 )
518 .await
519 .map_err(|_| anyhow::anyhow!("git fetch timed out after 120s"))
520 .and_then(|r| r.map_err(Into::into));
521
522 match fetch {
523 Ok(o) if o.status.success() => {}
524 Ok(o) => {
525 let stderr = String::from_utf8_lossy(&o.stderr);
526 anyhow::bail!("failed to fetch origin/{branch}: {stderr}");
527 }
528 Err(e) => anyhow::bail!("failed to fetch origin/{branch}: {e}"),
529 }
530
531 let count_output = Command::new("git")
533 .args(["rev-list", "--count", &format!("{origin_branch}..HEAD")])
534 .current_dir(dir)
535 .output_with_context()
536 .await;
537
538 let commit_count: usize = match count_output {
539 Ok(o) if o.status.success() => {
540 let raw = String::from_utf8_lossy(&o.stdout);
541 raw.trim().parse().map_err(|e| {
542 anyhow::anyhow!(
543 "failed to parse commit count from git rev-list output {:?}: {e}",
544 raw.trim()
545 )
546 })?
547 }
548 Ok(o) => {
549 let stderr = String::from_utf8_lossy(&o.stderr);
550 anyhow::bail!("git rev-list --count failed: {stderr}");
551 }
552 Err(e) => anyhow::bail!("failed to run git rev-list --count: {e}"),
553 };
554
555 if commit_count == 0 {
556 return Ok(false);
558 }
559
560 if commit_count > MAX_REBASE_COMMITS {
561 anyhow::bail!(
562 "refusing to rebase {commit_count} commits on origin/{branch} (max {})",
563 MAX_REBASE_COMMITS
564 );
565 }
566
567 tracing::info!(
568 branch = branch,
569 commit_count,
570 "rebasing local commits on origin/{branch}"
571 );
572
573 match stash_rebase_restore(dir, &origin_branch, StashRebaseOpts::default()).await? {
574 RebaseOutcome::Succeeded => Ok(true),
575 RebaseOutcome::Failed(e) => anyhow::bail!("rebase failed: {e}"),
576 RebaseOutcome::Skipped(reason) => anyhow::bail!("rebase skipped: {reason}"),
577 }
578}
579
580pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
588 if rebase_in_progress(dir).await {
590 tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
591 return;
592 }
593
594 let _ = Command::new("git")
597 .args(["fetch", "origin"])
598 .current_dir(dir)
599 .output_with_context()
600 .await;
601
602 let commit_count = Command::new("git")
607 .args([
608 "rev-list",
609 "--count",
610 &format!("origin/{default_branch}..HEAD"),
611 ])
612 .current_dir(dir)
613 .output_with_context()
614 .await
615 .ok()
616 .and_then(|o| {
617 String::from_utf8_lossy(&o.stdout)
618 .trim()
619 .parse::<usize>()
620 .ok()
621 })
622 .unwrap_or(0);
623
624 if commit_count > MAX_REBASE_COMMITS {
625 tracing::warn!(
626 default_branch,
627 commit_count,
628 max = MAX_REBASE_COMMITS,
629 "skipping rebase: branch has too many commits to replay safely"
630 );
631 return;
632 }
633
634 match stash_rebase_restore(
635 dir,
636 &format!("origin/{default_branch}"),
637 StashRebaseOpts::default(),
638 )
639 .await
640 {
641 Ok(RebaseOutcome::Succeeded) => {
642 tracing::debug!(default_branch, "rebased worktree on default branch")
643 }
644 Ok(RebaseOutcome::Failed(e)) => {
645 tracing::warn!(default_branch, err = %e, "rebase failed, continuing with current state")
646 }
647 Ok(RebaseOutcome::Skipped(reason)) => {
648 tracing::warn!(default_branch, %reason, "stash failed before rebase, continuing with current state")
649 }
650 Err(e) => tracing::warn!(err = %e, "rebase error"),
651 }
652}
653
654async fn rebase_in_progress(dir: &Path) -> bool {
655 for name in ["rebase-merge", "rebase-apply"] {
656 let out = Command::new("git")
657 .args(["rev-parse", "--git-path", name])
658 .current_dir(dir)
659 .output_with_context()
660 .await;
661 if let Ok(o) = out {
662 let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
663 if !p.is_empty() && dir.join(p).exists() {
664 return true;
665 }
666 }
667 }
668 false
669}
670
671pub(crate) fn build_git_auth_env() -> Vec<(String, String)> {
690 let token = crate::github::token::TokenResolver::default_env()
691 .get_token_sync()
692 .ok()
693 .flatten();
694 build_git_auth_env_for_token(token)
695}
696
697fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
698 match token {
699 Some(t) if !t.is_empty() => {
700 let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
701 vec![
702 ("GIT_CONFIG_COUNT".into(), "2".into()),
703 ("GIT_CONFIG_KEY_0".into(), authed.clone()),
704 ("GIT_CONFIG_VALUE_0".into(), "https://github.com/".into()),
705 ("GIT_CONFIG_KEY_1".into(), authed),
706 ("GIT_CONFIG_VALUE_1".into(), "git@github.com:".into()),
707 ]
708 }
709 _ => {
710 vec![
711 ("GIT_CONFIG_COUNT".into(), "1".into()),
712 (
713 "GIT_CONFIG_KEY_0".into(),
714 "url.https://github.com/.insteadOf".into(),
715 ),
716 ("GIT_CONFIG_VALUE_0".into(), "git@github.com:".into()),
717 ]
718 }
719 }
720}
721
722pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
729 stderr.contains("without `workflow` scope")
730 || stderr.contains("without 'workflow' scope")
731 || (stderr.contains("refusing to allow")
732 && stderr.contains("workflow")
733 && stderr.contains("scope"))
734}
735
736async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
747 let output = Command::new("git")
749 .args([
750 "diff",
751 "--name-only",
752 &format!("origin/{default_branch}...HEAD"),
753 "--",
754 ".github/workflows/",
755 ])
756 .current_dir(dir)
757 .output_with_context()
758 .await?;
759
760 let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
761 .lines()
762 .filter(|l| !l.is_empty())
763 .map(|s| s.to_string())
764 .collect();
765
766 if workflow_files.is_empty() {
767 return Ok(false);
768 }
769
770 tracing::info!(
771 files = ?workflow_files,
772 "stripping workflow files from commits to bypass missing workflow scope"
773 );
774
775 let count_output = Command::new("git")
780 .args([
781 "rev-list",
782 "--count",
783 &format!("origin/{default_branch}..HEAD"),
784 ])
785 .current_dir(dir)
786 .output_with_context()
787 .await?;
788
789 let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
790 .trim()
791 .parse()
792 .unwrap_or(0);
793
794 if commit_count == 0 {
795 return Ok(false);
796 }
797
798 let log_output = Command::new("git")
803 .args([
804 "log",
805 "--format=%x00%H%n%B",
806 &format!("origin/{default_branch}..HEAD"),
807 ])
808 .current_dir(dir)
809 .output_with_context()
810 .await?;
811 let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
812
813 let commits: Vec<(String, String)> = log_text
817 .split('\0')
818 .filter(|s| !s.trim().is_empty())
819 .map(|block| {
820 let block = block.trim();
821 let first_nl = block.find('\n').unwrap_or(block.len());
822 let hash = block[..first_nl].to_string();
823 let msg = block[first_nl..].trim().to_string();
824 (hash, msg)
825 })
826 .collect::<Vec<_>>()
827 .into_iter()
828 .rev()
829 .collect();
830
831 if commits.is_empty() {
832 return Ok(false);
833 }
834
835 let reset = Command::new("git")
837 .args(["reset", "--soft", &format!("origin/{default_branch}")])
838 .current_dir(dir)
839 .output_with_context()
840 .await?;
841
842 if !reset.status.success() {
843 let stderr = String::from_utf8_lossy(&reset.stderr);
844 anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
845 }
846
847 let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
849 let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
850 rm_args.extend(file_refs);
851
852 let rm = Command::new("git")
853 .args(&rm_args)
854 .current_dir(dir)
855 .output_with_context()
856 .await?;
857
858 if !rm.status.success() {
859 let stderr = String::from_utf8_lossy(&rm.stderr);
860 tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
861 }
862
863 let has_staged = Command::new("git")
866 .args(["diff", "--cached", "--quiet"])
867 .current_dir(dir)
868 .status()
869 .await
870 .map(|s| !s.success())
871 .unwrap_or(false);
872
873 if !has_staged {
874 tracing::warn!(
875 "after removing workflow files, no staged changes remain — \
876 the only changes were workflow files"
877 );
878 if let Some((hash, _)) = commits.last() {
881 let _ = Command::new("git")
882 .args(["reset", "--soft", hash])
883 .current_dir(dir)
884 .output_with_context()
885 .await;
886 }
887 return Ok(false);
888 }
889
890 let commit_msg = if commits.len() == 1 {
892 commits[0].1.clone()
893 } else {
894 let mut combined = String::new();
896 for (i, (_, msg)) in commits.iter().enumerate() {
897 if i > 0 {
898 combined.push_str("\n\n");
899 }
900 combined.push_str(msg);
901 }
902 combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
903 combined
904 };
905
906 let (git_name, git_email) = git_identity("orchestrator");
907 let commit = Command::new("git")
908 .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
910 .env("GIT_AUTHOR_NAME", &git_name)
911 .env("GIT_COMMITTER_NAME", &git_name)
912 .env("GIT_AUTHOR_EMAIL", &git_email)
913 .env("GIT_COMMITTER_EMAIL", &git_email)
914 .current_dir(dir)
915 .output_with_context()
916 .await?;
917
918 if !commit.status.success() {
919 let stderr = String::from_utf8_lossy(&commit.stderr);
920 if let Some((original_head, _)) = commits.last() {
922 let _ = Command::new("git")
923 .args(["reset", "--hard", original_head])
924 .current_dir(dir)
925 .output_with_context()
926 .await;
927 }
928 anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
929 }
930
931 tracing::info!(
932 stripped_files = ?workflow_files,
933 original_commits = commit_count,
934 "successfully stripped workflow files from commits"
935 );
936
937 Ok(true)
938}
939
940pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
942 let current = get_current_branch(dir).await;
943 let branch_to_push = if !current.is_empty() {
944 ¤t
945 } else {
946 branch
947 };
948
949 if branch_to_push.is_empty() {
951 anyhow::bail!("cannot push: branch name is empty");
952 }
953
954 if branch_to_push == "main" || branch_to_push == "master" {
956 return Ok(false);
957 }
958
959 let (remote_exists, has_unpushed) =
961 check_push_needed(dir, branch_to_push, default_branch).await;
962 if remote_exists && !has_unpushed {
963 tracing::debug!(
967 branch = branch_to_push,
968 "remote branch exists and is up to date, skipping push"
969 );
970 return Ok(true);
971 }
972 if !remote_exists && !has_unpushed {
973 tracing::debug!(
979 branch = branch_to_push,
980 "remote branch missing with no new commits — pushing to create it on remote"
981 );
982 }
983
984 tracing::info!(branch = branch_to_push, "pushing branch");
985
986 let auth_env = build_git_auth_env();
987
988 let output = tokio::time::timeout(
995 std::time::Duration::from_secs(120),
996 Command::new("git")
997 .args([
998 "-c",
999 "lfs.locksverify=false",
1000 "push",
1001 "-u",
1002 "origin",
1003 branch_to_push,
1004 ])
1005 .kill_on_drop(true)
1006 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1007 .current_dir(dir)
1008 .output(),
1009 )
1010 .await
1011 .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1012
1013 if output.status.success() {
1014 tracing::info!(branch = branch_to_push, "push succeeded");
1015 return Ok(true);
1016 }
1017
1018 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1019
1020 if is_transient_network_error(&stderr) {
1023 tracing::warn!(
1024 branch = branch_to_push,
1025 stderr = %sanitize_push_error(&stderr),
1026 "push failed with transient network error — retrying once"
1027 );
1028 let retry_output = tokio::time::timeout(
1029 std::time::Duration::from_secs(120),
1030 Command::new("git")
1031 .args([
1032 "-c",
1033 "lfs.locksverify=false",
1034 "push",
1035 "-u",
1036 "origin",
1037 branch_to_push,
1038 ])
1039 .kill_on_drop(true)
1040 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1041 .current_dir(dir)
1042 .output(),
1043 )
1044 .await
1045 .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1046
1047 if retry_output.status.success() {
1048 tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1049 return Ok(true);
1050 }
1051
1052 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1053 .trim()
1054 .to_string();
1055 tracing::warn!(
1056 branch = branch_to_push,
1057 stderr = %sanitize_push_error(&retry_stderr),
1058 "push retry also failed"
1059 );
1060 anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1061 }
1062
1063 if is_workflow_scope_error(&stderr) {
1067 tracing::warn!(
1068 branch = branch_to_push,
1069 "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1070 );
1071
1072 match strip_workflow_files(dir, default_branch).await {
1073 Ok(true) => {
1074 let retry_output = tokio::time::timeout(
1076 std::time::Duration::from_secs(120),
1077 Command::new("git")
1078 .args([
1079 "-c",
1080 "lfs.locksverify=false",
1081 "push",
1082 "-u",
1083 "origin",
1084 branch_to_push,
1085 ])
1086 .kill_on_drop(true)
1087 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1088 .current_dir(dir)
1089 .output(),
1090 )
1091 .await
1092 .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1093
1094 if retry_output.status.success() {
1095 tracing::info!(
1096 branch = branch_to_push,
1097 "push succeeded after stripping workflow files"
1098 );
1099 return Ok(true);
1100 }
1101
1102 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1103 .trim()
1104 .to_string();
1105
1106 if is_workflow_scope_error(&retry_stderr) {
1108 anyhow::bail!(
1109 "push failed: token lacks `workflow` OAuth scope and workflow files \
1110 could not be fully stripped — add `workflow` scope to your GitHub \
1111 token or use a GitHub App for authentication"
1112 );
1113 }
1114
1115 anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1116 }
1117 Ok(false) => {
1118 anyhow::bail!(
1120 "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1121 to your GitHub token or use a GitHub App for authentication. \
1122 Error: {stderr}"
1123 );
1124 }
1125 Err(e) => {
1126 tracing::error!(error = %e, "failed to strip workflow files from commits");
1127 anyhow::bail!(
1128 "push failed: token lacks `workflow` OAuth scope and automatic \
1129 recovery failed ({e}) — add `workflow` scope to your GitHub token \
1130 or use a GitHub App for authentication"
1131 );
1132 }
1133 }
1134 }
1135
1136 if push_needs_rebase(&stderr) && remote_exists {
1141 tracing::info!(
1142 branch = branch_to_push,
1143 "push rejected (non-fast-forward), force-pushing with lease"
1144 );
1145 let output = Command::new("git")
1146 .args([
1147 "-c",
1148 "lfs.locksverify=false",
1149 "push",
1150 "--force-with-lease",
1151 "-u",
1152 "origin",
1153 branch_to_push,
1154 ])
1155 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1156 .current_dir(dir)
1157 .output_with_context()
1158 .await?;
1159
1160 if output.status.success() {
1161 tracing::info!(branch = branch_to_push, "force push succeeded");
1162 return Ok(true);
1163 }
1164
1165 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1166
1167 if is_workflow_scope_error(&stderr) {
1169 tracing::warn!(
1170 branch = branch_to_push,
1171 "force push also rejected due to workflow scope — stripping and retrying"
1172 );
1173 match strip_workflow_files(dir, default_branch).await {
1174 Ok(true) => {
1175 let retry_output = Command::new("git")
1176 .args([
1177 "-c",
1178 "lfs.locksverify=false",
1179 "push",
1180 "--force-with-lease",
1181 "-u",
1182 "origin",
1183 branch_to_push,
1184 ])
1185 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1186 .current_dir(dir)
1187 .output_with_context()
1188 .await?;
1189
1190 if retry_output.status.success() {
1191 tracing::info!(
1192 branch = branch_to_push,
1193 "force push succeeded after stripping workflow files"
1194 );
1195 return Ok(true);
1196 }
1197
1198 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1199 .trim()
1200 .to_string();
1201 anyhow::bail!(
1202 "force push failed after stripping workflow files: {retry_stderr}"
1203 );
1204 }
1205 Ok(false) => {
1206 anyhow::bail!(
1207 "force push failed: token lacks `workflow` OAuth scope — \
1208 add `workflow` scope to your GitHub token or use a GitHub App. \
1209 Error: {stderr}"
1210 );
1211 }
1212 Err(e) => {
1213 anyhow::bail!(
1214 "force push failed: token lacks `workflow` scope and recovery \
1215 failed ({e})"
1216 );
1217 }
1218 }
1219 }
1220
1221 anyhow::bail!("force push failed: {stderr}");
1222 }
1223
1224 anyhow::bail!("push failed: {stderr}")
1225}
1226
1227fn push_needs_rebase(stderr: &str) -> bool {
1228 let lower = stderr.to_ascii_lowercase();
1229 lower.contains("non-fast-forward")
1230 || lower.contains("rejected")
1231 || lower.contains("fetch first")
1232 || lower.contains("behind")
1233}
1234
1235fn is_transient_network_error(stderr: &str) -> bool {
1240 let lower = stderr.to_ascii_lowercase();
1241 lower.contains("i/o timeout")
1242 || lower.contains("dial tcp")
1243 || lower.contains("connection reset")
1244 || lower.contains("connection refused")
1245 || lower.contains("no route to host")
1246 || lower.contains("temporary failure in name resolution")
1247 || lower.contains("eof")
1248}
1249
1250fn sanitize_push_error(stderr: &str) -> String {
1254 static URL_USERINFO: LazyLock<Regex> =
1255 LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1256 let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1257 crate::security::redact(&no_userinfo)
1258}
1259
1260#[allow(clippy::too_many_arguments)]
1269pub async fn create_pr_if_needed(
1270 dir: &Path,
1271 branch: &str,
1272 title: &str,
1273 summary: &str,
1274 accomplished: &[String],
1275 remaining: &[String],
1276 files: &[String],
1277 task_id: &str,
1278 agent: &str,
1279 model: Option<&str>,
1280 repo: &str,
1281 base_branch: &str,
1282) -> PrCreateResult<String> {
1283 let gh = GhHttp::new()?;
1284
1285 match gh.get_pr_number(repo, branch).await {
1287 Ok(Some(pr_number)) => {
1288 tracing::info!(task_id, pr = pr_number, "PR already exists");
1289 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1291 let pr = match gh.get_pr(repo, pr_number).await {
1293 Ok(pr) => pr,
1294 Err(e) => {
1295 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1296 return Err(PrCreateError::ApiError(e));
1297 }
1298 };
1299 return Ok(pr.html_url);
1300 }
1301 Ok(None) => {
1302 }
1304 Err(e) => {
1305 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1306 return Err(PrCreateError::ApiError(e));
1307 }
1308 }
1309
1310 let mut body = format!(
1312 "## Summary\n\n{}",
1313 if summary.is_empty() { title } else { summary }
1314 );
1315
1316 if !accomplished.is_empty() {
1317 body.push_str("\n\n### What was done\n\n");
1318 for item in accomplished {
1319 body.push_str(&format!("- {item}\n"));
1320 }
1321 }
1322
1323 if !remaining.is_empty() {
1324 body.push_str("\n\n### Remaining\n\n");
1325 for item in remaining {
1326 body.push_str(&format!("- {item}\n"));
1327 }
1328 }
1329
1330 let git_files: Vec<String>;
1332 let effective_files: &[String] = if files.is_empty() {
1333 match tokio::process::Command::new("git")
1334 .args([
1335 "diff",
1336 &format!("origin/{base_branch}...HEAD"),
1337 "--name-only",
1338 ])
1339 .current_dir(dir)
1340 .output()
1341 .await
1342 {
1343 Ok(output) if output.status.success() => {
1344 git_files = String::from_utf8_lossy(&output.stdout)
1345 .lines()
1346 .filter(|l| !l.is_empty())
1347 .map(|l| l.to_string())
1348 .collect();
1349 &git_files
1350 }
1351 _ => files,
1352 }
1353 } else {
1354 files
1355 };
1356
1357 if !effective_files.is_empty() {
1358 body.push_str("\n\n### Files changed\n\n");
1359 for file in effective_files {
1360 body.push_str(&format!("- `{file}`\n"));
1361 }
1362 }
1363
1364 if !task_id.starts_with("internal:") {
1367 if let Ok(issue_num) = task_id.parse::<u64>() {
1368 body.push_str(&format!("\n\nCloses #{issue_num}"));
1369 }
1370 }
1371
1372 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1373 let task_ref = format_task_ref(task_id);
1374 body.push_str(&format!(
1375 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1376 ));
1377
1378 let pr_title = title;
1380
1381 const MAX_RETRIES: u32 = 3;
1383 let mut last_error: Option<anyhow::Error> = None;
1384 let mut created_url: Option<String> = None;
1385 let mut had_transient_error = false;
1386 let mut effective_base = base_branch.to_string();
1387 let mut tried_api_fallback = false;
1388
1389 for attempt in 1..=MAX_RETRIES {
1390 match gh
1391 .create_pr(repo, pr_title, &body, branch, &effective_base)
1392 .await
1393 {
1394 Ok(u) => {
1395 created_url = Some(u);
1396 break;
1397 }
1398 Err(e) => {
1399 let err_str = format!("{e}");
1400 if is_transient_github_error(&err_str) {
1401 had_transient_error = true;
1402 if attempt < MAX_RETRIES {
1403 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1404 tracing::warn!(
1405 task_id,
1406 attempt,
1407 delay_secs = delay.as_secs(),
1408 error = %e,
1409 "transient GitHub API error during PR creation — retrying"
1410 );
1411 tokio::time::sleep(delay).await;
1412 }
1413 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1414 tried_api_fallback = true;
1417 tracing::warn!(
1418 task_id,
1419 base_branch = %effective_base,
1420 error = %e,
1421 "PR creation failed with invalid base — fetching default branch from GitHub API"
1422 );
1423 match gh.get_default_branch(repo).await {
1424 Ok(api_default) if api_default != effective_base => {
1425 tracing::info!(
1426 task_id,
1427 old_base = %effective_base,
1428 new_base = %api_default,
1429 "retrying PR creation with GitHub API default branch"
1430 );
1431 effective_base = api_default;
1432 continue;
1433 }
1434 Ok(api_default) => {
1435 tracing::warn!(
1436 task_id,
1437 base_branch = %api_default,
1438 "GitHub API default branch matches local — no fallback possible"
1439 );
1440 }
1441 Err(api_err) => {
1442 tracing::warn!(
1443 task_id,
1444 error = %api_err,
1445 "failed to fetch default branch from GitHub API"
1446 );
1447 }
1448 }
1449 }
1450 last_error = Some(e);
1451 }
1452 }
1453 }
1454
1455 let url = match created_url {
1456 Some(u) => u,
1457 None => {
1458 let e = last_error.unwrap_or_else(|| {
1459 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1460 });
1461 let err_str = format!("{e}");
1462 if is_transient_github_error(&err_str) || had_transient_error {
1469 tracing::warn!(
1470 task_id,
1471 error = %e,
1472 "transient GitHub API error after all retries — checking if PR was actually created"
1473 );
1474 match gh.get_pr_number(repo, branch).await {
1475 Ok(Some(pr_number)) => {
1476 tracing::info!(
1477 task_id,
1478 pr = pr_number,
1479 "PR was created despite transient errors — recovering"
1480 );
1481 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1482 .await;
1483 match gh.get_pr(repo, pr_number).await {
1484 Ok(pr) => pr.html_url,
1485 Err(get_err) => {
1486 tracing::warn!(
1487 task_id,
1488 error = %get_err,
1489 "failed to fetch PR URL after transient-error recovery"
1490 );
1491 return Err(PrCreateError::ApiError(e));
1492 }
1493 }
1494 }
1495 Ok(None) => {
1496 tracing::warn!(
1497 task_id,
1498 "PR was not created after all retries — propagating original error"
1499 );
1500 return Err(PrCreateError::ApiError(e));
1501 }
1502 Err(check_err) => {
1503 tracing::warn!(
1504 task_id,
1505 error = %check_err,
1506 "failed to verify PR existence after transient error retries — propagating original error"
1507 );
1508 return Err(PrCreateError::ApiError(e));
1509 }
1510 }
1511 } else {
1512 return Err(PrCreateError::ApiError(e));
1513 }
1514 }
1515 };
1516
1517 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1518
1519 Ok(url)
1520}
1521
1522pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1526 if err_str.contains("HTTP send failed") {
1530 return true;
1531 }
1532 if err_str.contains("GitHub API server error") {
1533 return true;
1534 }
1535
1536 if extract_github_http_status(err_str)
1538 .map(|s| (500..600).contains(&s))
1539 .unwrap_or(false)
1540 {
1541 return true;
1542 }
1543
1544 let lower = err_str.to_lowercase();
1546
1547 if lower.contains("connection reset")
1549 || lower.contains("broken pipe")
1550 || lower.contains("connection refused")
1551 || lower.contains("connection closed")
1552 || lower.contains("transport error")
1553 || lower.contains("failed to connect")
1554 || lower.contains("failed to connect to github.com port")
1555 || lower.contains("curl: (7) failed to connect")
1556 || lower.contains("couldn't connect to server")
1557 || lower.contains("could not connect to server")
1558 {
1559 return true;
1560 }
1561
1562 if lower.contains("timeout") {
1564 return true;
1565 }
1566
1567 if lower.contains("dns error")
1569 || lower.contains("resolve")
1570 || lower.contains("name resolution")
1571 || lower.contains("no such host")
1572 {
1573 return true;
1574 }
1575
1576 if lower.contains("tls handshake")
1578 || lower.contains("certificate")
1579 || lower.contains("ssl error")
1580 {
1581 return true;
1582 }
1583
1584 if lower.contains("network is unreachable")
1586 || lower.contains("host unreachable")
1587 || lower.contains("temporary failure")
1588 {
1589 return true;
1590 }
1591
1592 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1594 return true;
1595 }
1596
1597 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1599 return true;
1600 }
1601
1602 if lower.contains("rate limit") || lower.contains("too many requests") {
1604 return true;
1605 }
1606
1607 false
1608}
1609
1610pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1616 let lower = err_str.to_lowercase();
1617 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1618}
1619
1620pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1628 if !is_transient_github_error(err_str) {
1629 return false;
1630 }
1631
1632 if extract_github_http_status(err_str).is_some() {
1633 return true;
1634 }
1635
1636 let lower = err_str.to_lowercase();
1637 lower.contains("github")
1638 || lower.contains("api.github.com")
1639 || lower.contains("gh api")
1640 || lower.contains("gh pr")
1641 || lower.contains("gh error")
1642 || lower.contains("circuit-breaker")
1647 || lower.contains("circuit breaker")
1648}
1649
1650fn extract_github_http_status(err_str: &str) -> Option<u16> {
1655 let marker = "failed (";
1656 let start = err_str.find(marker)? + marker.len();
1657 let rest = &err_str[start..];
1658 let end = rest.find(')')?;
1659 rest[..end].parse().ok()
1660}
1661
1662async fn append_pr_footer_if_missing(
1667 gh: &GhHttp,
1668 repo: &str,
1669 pr_number: u64,
1670 task_id: &str,
1671 agent: &str,
1672 model: Option<&str>,
1673) {
1674 let pr = match gh.get_pr(repo, pr_number).await {
1675 Ok(pr) => pr,
1676 Err(e) => {
1677 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1678 return;
1679 }
1680 };
1681
1682 let body = pr.body.unwrap_or_default();
1683 if body.contains("via [Orch]") {
1684 return; }
1686
1687 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1688 let task_ref = format_task_ref(task_id);
1689 let footer = format!(
1690 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1691 );
1692 let new_body = format!("{body}{footer}");
1693
1694 if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1695 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1696 } else {
1697 tracing::info!(
1698 task_id,
1699 pr = pr_number,
1700 "appended attribution footer to agent-created PR"
1701 );
1702 }
1703}
1704
1705async fn get_current_branch(dir: &Path) -> String {
1707 let output = Command::new("git")
1708 .args(["branch", "--show-current"])
1709 .current_dir(dir)
1710 .output_with_context()
1711 .await;
1712
1713 match output {
1714 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1715 _ => String::new(),
1716 }
1717}
1718
1719pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1721 let output = Command::new("git")
1723 .args(["status", "--porcelain"])
1724 .current_dir(dir)
1725 .output_with_context()
1726 .await?;
1727
1728 let status_output = String::from_utf8_lossy(&output.stdout);
1729 let count = status_output
1730 .lines()
1731 .filter(|line| {
1732 let prefix = line.get(0..2).unwrap_or("");
1733 prefix.starts_with('M')
1735 || prefix.starts_with('A')
1736 || prefix.starts_with('R')
1737 || prefix.starts_with('C')
1738 || prefix.starts_with('?')
1739 })
1740 .count();
1741
1742 Ok(count)
1743}
1744
1745async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1753 let remote_exists = Command::new("git")
1755 .args(["rev-parse", &format!("origin/{branch}")])
1756 .current_dir(dir)
1757 .output_with_context()
1758 .await
1759 .map(|o| o.status.success())
1760 .unwrap_or(false);
1761
1762 let compare_ref = if remote_exists {
1763 format!("origin/{branch}..HEAD")
1764 } else {
1765 format!("origin/{default_branch}..HEAD")
1768 };
1769
1770 tracing::debug!(
1771 branch,
1772 compare_ref,
1773 remote_exists,
1774 "checking unpushed commits"
1775 );
1776
1777 let output = Command::new("git")
1778 .args(["log", &compare_ref, "--oneline"])
1779 .current_dir(dir)
1780 .output_with_context()
1781 .await;
1782
1783 let has_unpushed = match output {
1784 Ok(o) if o.status.success() => {
1785 let out = String::from_utf8_lossy(&o.stdout);
1786 let has = !out.trim().is_empty();
1787 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1788 has
1789 }
1790 _ => {
1791 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1792 true
1795 }
1796 };
1797
1798 (remote_exists, has_unpushed)
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803 use super::*;
1804
1805 #[test]
1806 fn pr_create_error_display() {
1807 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1808 assert_eq!(format!("{}", err), "GitHub API error: test error");
1809 }
1810
1811 #[test]
1812 fn pr_create_error_from_anyhow() {
1813 let anyhow_err = anyhow::anyhow!("source error");
1814 let pr_err: PrCreateError = anyhow_err.into();
1815 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1816 }
1817
1818 #[test]
1819 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1820 let env = build_git_auth_env_for_token(None);
1823
1824 let has_ssh_rule = env
1827 .iter()
1828 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
1829 assert!(
1830 has_ssh_rule,
1831 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
1832 );
1833 }
1834
1835 #[test]
1836 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
1837 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
1839
1840 let has_token = env
1842 .iter()
1843 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
1844 assert!(has_token, "expected token in auth URL, got: {env:?}");
1845 let has_https_rule = env
1847 .iter()
1848 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
1849 assert!(
1850 has_https_rule,
1851 "expected HTTPS insteadOf rule, got: {env:?}"
1852 );
1853 let has_ssh_rule = env
1855 .iter()
1856 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
1857 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
1858 }
1859
1860 #[test]
1861 fn find_stash_ref_by_hash_returns_matching_reference() {
1862 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
1863
1864 assert_eq!(
1865 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
1866 Some("stash@{1}".to_string())
1867 );
1868 assert_eq!(
1869 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
1870 Some("stash@{0}".to_string())
1871 );
1872 }
1873
1874 #[test]
1875 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
1876 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
1877
1878 assert_eq!(
1879 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
1880 None
1881 );
1882 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
1883 }
1884
1885 #[test]
1886 fn push_needs_rebase_detects_common_rejections() {
1887 assert!(push_needs_rebase(
1888 "! [rejected] branch -> branch (fetch first)"
1889 ));
1890 assert!(push_needs_rebase("non-fast-forward update was rejected"));
1891 assert!(push_needs_rebase(
1892 "push rejected because the remote contains work"
1893 ));
1894 }
1895
1896 #[test]
1897 fn is_transient_network_error_detects_lfs_locks_timeout() {
1898 let lfs_err = "Post \"https://x-access-token:***@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
1899 assert!(is_transient_network_error(lfs_err));
1900 assert!(is_transient_network_error(
1901 "dial tcp 140.82.112.4:443: connect: connection refused"
1902 ));
1903 assert!(is_transient_network_error(
1904 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
1905 ));
1906 }
1907
1908 #[test]
1909 fn is_transient_network_error_rejects_ref_rejections() {
1910 assert!(!is_transient_network_error(
1911 "! [rejected] branch -> branch (fetch first)"
1912 ));
1913 assert!(!is_transient_network_error(
1914 "remote: error: GH006: Protected branch update failed"
1915 ));
1916 }
1917
1918 #[test]
1919 fn sanitize_push_error_strips_url_credentials() {
1920 let raw = "Post \"https://x-access-token:test-secret-1234567890abcdef@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
1922 let sanitized = sanitize_push_error(raw);
1923 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
1924 assert!(sanitized.contains("://***@github.com"));
1925 assert!(sanitized.contains("i/o timeout"));
1926 }
1927
1928 #[test]
1929 fn extract_github_http_status_parses_ghhttp_error_format() {
1930 let err =
1931 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
1932 assert_eq!(extract_github_http_status(err), Some(502));
1933
1934 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
1935 assert_eq!(extract_github_http_status(err500), Some(500));
1936
1937 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
1938 assert_eq!(extract_github_http_status(err422), Some(422));
1939
1940 let not_github = "some other error";
1941 assert_eq!(extract_github_http_status(not_github), None);
1942 }
1943
1944 #[test]
1945 fn is_transient_github_error_matches_5xx_only() {
1946 let err502 =
1947 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
1948 assert!(is_transient_github_error(err502));
1949
1950 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
1951 assert!(is_transient_github_error(err503));
1952
1953 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
1954 assert!(!is_transient_github_error(err422));
1955
1956 let err404 =
1957 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
1958 assert!(!is_transient_github_error(err404));
1959 }
1960
1961 #[test]
1962 fn is_transient_github_error_matches_connection_errors() {
1963 assert!(is_transient_github_error("connection refused"));
1965 assert!(is_transient_github_error("Connection reset by peer"));
1966 assert!(is_transient_github_error("broken pipe"));
1967 assert!(is_transient_github_error("connection closed unexpectedly"));
1968 assert!(is_transient_github_error(
1969 "transport error: connection lost"
1970 ));
1971
1972 assert!(is_transient_github_error(
1974 "fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
1975 ));
1976 assert!(is_transient_github_error(
1977 "Failed to connect to github.com port 443: Connection timed out"
1978 ));
1979 assert!(is_transient_github_error(
1980 "Failed to connect to github.com port 443: Connection refused"
1981 ));
1982 assert!(is_transient_github_error(
1983 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
1984 ));
1985 assert!(is_transient_github_error(
1986 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
1987 ));
1988 }
1989
1990 #[test]
1991 fn is_transient_github_error_matches_dns_errors() {
1992 assert!(is_transient_github_error("dns error: failed to lookup"));
1994 assert!(is_transient_github_error("failed to resolve hostname"));
1995 assert!(is_transient_github_error("name resolution failed"));
1996 assert!(is_transient_github_error("no such host: api.github.com"));
1997 }
1998
1999 #[test]
2000 fn is_transient_github_error_matches_tls_errors() {
2001 assert!(is_transient_github_error("tls handshake failed"));
2003 assert!(is_transient_github_error("certificate verification failed"));
2004 assert!(is_transient_github_error("ssl error: unknown ca"));
2005 }
2006
2007 #[test]
2008 fn is_transient_github_error_matches_network_unreachable() {
2009 assert!(is_transient_github_error("network is unreachable"));
2011 assert!(is_transient_github_error("host unreachable"));
2012 assert!(is_transient_github_error(
2013 "temporary failure in name resolution"
2014 ));
2015 }
2016
2017 #[test]
2018 fn is_transient_github_error_matches_eof_errors() {
2019 assert!(is_transient_github_error("unexpected eof while reading"));
2021 assert!(is_transient_github_error("unexpected end of file"));
2022 }
2023
2024 #[test]
2025 fn is_transient_github_error_matches_rate_limit() {
2026 assert!(is_transient_github_error("rate limit exceeded"));
2028 assert!(is_transient_github_error("429 Too Many Requests"));
2029 assert!(is_transient_github_error("too many requests, retry later"));
2030 }
2031
2032 #[test]
2033 fn is_transient_github_error_matches_timeout() {
2034 assert!(is_transient_github_error("operation timeout"));
2036 assert!(is_transient_github_error("request timeout"));
2037 }
2038
2039 #[test]
2040 fn is_transient_github_error_rejects_non_transient() {
2041 assert!(!is_transient_github_error("permission denied"));
2043 assert!(!is_transient_github_error("file not found"));
2044 assert!(!is_transient_github_error("invalid argument"));
2045 assert!(!is_transient_github_error("authentication failed"));
2046 assert!(!is_transient_github_error("bad credentials"));
2047 }
2048
2049 #[test]
2050 fn is_transient_github_api_error_requires_github_context() {
2051 assert!(is_transient_github_api_error(
2052 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2053 ));
2054 assert!(is_transient_github_api_error(
2055 "gh error: transport error: connection reset by peer"
2056 ));
2057 assert!(is_transient_github_api_error(
2058 "push failed: fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
2059 ));
2060 assert!(!is_transient_github_api_error(
2061 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2062 ));
2063 assert!(!is_transient_github_api_error(
2064 "agent stream failed: failed to connect to local relay"
2065 ));
2066 }
2067
2068 #[test]
2069 fn is_transient_github_api_error_matches_circuit_breaker() {
2070 assert!(is_transient_github_api_error(
2072 "GitHub API transient 5xx circuit-breaker active for 240s"
2073 ));
2074 assert!(is_transient_github_api_error(
2076 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2077 ));
2078 assert!(is_transient_github_api_error(
2081 "create PR failed: circuit-breaker"
2082 ));
2083 assert!(is_transient_github_api_error(
2085 "create PR failed: circuit breaker tripped"
2086 ));
2087 }
2088
2089 #[tokio::test]
2090 async fn auto_commit_unstages_files_on_commit_failure() {
2091 let dir =
2093 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2094 let _ = std::fs::remove_dir_all(&dir);
2095 std::fs::create_dir_all(&dir).unwrap();
2096
2097 let _ = Command::new("git")
2099 .args(["init"])
2100 .current_dir(&dir)
2101 .output()
2102 .await;
2103 let _ = Command::new("git")
2104 .args(["config", "user.email", "test@test.com"])
2105 .current_dir(&dir)
2106 .output()
2107 .await;
2108 let _ = Command::new("git")
2109 .args(["config", "user.name", "Test"])
2110 .current_dir(&dir)
2111 .output()
2112 .await;
2113 std::fs::write(dir.join("init.txt"), "init").unwrap();
2115 let _ = Command::new("git")
2116 .args(["add", "init.txt"])
2117 .current_dir(&dir)
2118 .output()
2119 .await;
2120 let _ = Command::new("git")
2121 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2122 .current_dir(&dir)
2123 .output()
2124 .await;
2125
2126 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2128
2129 assert!(has_changes(&dir).await);
2131
2132 let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2137 assert!(result.unwrap());
2138
2139 assert!(!has_changes(&dir).await);
2141
2142 let _ = std::fs::remove_dir_all(&dir);
2144 }
2145
2146 #[tokio::test]
2147 async fn auto_commit_ignores_repo_signing_config() {
2148 let dir =
2151 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2152 let _ = std::fs::remove_dir_all(&dir);
2153 std::fs::create_dir_all(&dir).unwrap();
2154
2155 for args in [
2156 vec!["init"],
2157 vec!["config", "user.email", "test@test.com"],
2158 vec!["config", "user.name", "Test"],
2159 vec!["config", "commit.gpgsign", "true"],
2160 vec!["config", "gpg.program", "/usr/bin/false"],
2161 ] {
2162 let _ = Command::new("git")
2163 .args(&args)
2164 .current_dir(&dir)
2165 .output()
2166 .await;
2167 }
2168 std::fs::write(dir.join("init.txt"), "init").unwrap();
2169 let _ = Command::new("git")
2170 .args(["add", "init.txt"])
2171 .current_dir(&dir)
2172 .output()
2173 .await;
2174 let _ = Command::new("git")
2175 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2176 .current_dir(&dir)
2177 .output()
2178 .await;
2179
2180 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2181 assert!(has_changes(&dir).await);
2182
2183 let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2184 assert!(result.unwrap());
2185 assert!(!has_changes(&dir).await);
2186
2187 let _ = std::fs::remove_dir_all(&dir);
2188 }
2189
2190 #[tokio::test]
2191 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2192 let dir =
2194 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2195 let _ = std::fs::remove_dir_all(&dir);
2196 std::fs::create_dir_all(&dir).unwrap();
2197
2198 let _ = Command::new("git")
2200 .args(["init"])
2201 .current_dir(&dir)
2202 .output()
2203 .await;
2204 let _ = Command::new("git")
2205 .args(["config", "user.email", "test@test.com"])
2206 .current_dir(&dir)
2207 .output()
2208 .await;
2209 let _ = Command::new("git")
2210 .args(["config", "user.name", "Test"])
2211 .current_dir(&dir)
2212 .output()
2213 .await;
2214 std::fs::write(dir.join("init.txt"), "init").unwrap();
2216 let _ = Command::new("git")
2217 .args(["add", "init.txt"])
2218 .current_dir(&dir)
2219 .output()
2220 .await;
2221 let _ = Command::new("git")
2222 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2223 .current_dir(&dir)
2224 .output()
2225 .await;
2226
2227 let hooks_dir = dir.join(".git/hooks");
2229 std::fs::create_dir_all(&hooks_dir).unwrap();
2230 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2231 #[cfg(unix)]
2232 {
2233 use std::os::unix::fs::PermissionsExt;
2234 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2235 .unwrap()
2236 .permissions();
2237 perms.set_mode(0o755);
2238 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2239 }
2240
2241 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2243
2244 assert!(has_changes(&dir).await);
2246
2247 let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2249 assert!(!result.unwrap());
2250
2251 assert!(has_changes(&dir).await);
2254
2255 let cached = Command::new("git")
2257 .args(["diff", "--cached", "--quiet"])
2258 .current_dir(&dir)
2259 .status()
2260 .await;
2261 assert!(
2262 cached.map(|s| s.success()).unwrap_or(false),
2263 "files should be unstaged after commit failure"
2264 );
2265
2266 let _ = std::fs::remove_dir_all(&dir);
2268 }
2269
2270 #[test]
2271 fn is_workflow_scope_error_detects_backtick_format() {
2272 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2273 assert!(is_workflow_scope_error(err));
2274 }
2275
2276 #[test]
2277 fn is_workflow_scope_error_detects_single_quote_format() {
2278 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2279 assert!(is_workflow_scope_error(err));
2280 }
2281
2282 #[test]
2283 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2284 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2285 assert!(is_workflow_scope_error(err));
2286 }
2287
2288 #[test]
2289 fn is_workflow_scope_error_rejects_unrelated_errors() {
2290 assert!(!is_workflow_scope_error("non-fast-forward"));
2291 assert!(!is_workflow_scope_error("permission denied"));
2292 assert!(!is_workflow_scope_error("authentication failed"));
2293 assert!(!is_workflow_scope_error("rejected"));
2294 }
2295
2296 #[tokio::test]
2297 async fn strip_workflow_files_removes_workflow_from_commits() {
2298 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2300 let _ = std::fs::remove_dir_all(&dir);
2301 std::fs::create_dir_all(&dir).unwrap();
2302
2303 let _ = Command::new("git")
2305 .args(["init"])
2306 .current_dir(&dir)
2307 .output()
2308 .await;
2309 let _ = Command::new("git")
2310 .args(["config", "user.email", "test@test.com"])
2311 .current_dir(&dir)
2312 .output()
2313 .await;
2314 let _ = Command::new("git")
2315 .args(["config", "user.name", "Test"])
2316 .current_dir(&dir)
2317 .output()
2318 .await;
2319 std::fs::write(dir.join("init.txt"), "init").unwrap();
2320 let _ = Command::new("git")
2321 .args(["add", "."])
2322 .current_dir(&dir)
2323 .output()
2324 .await;
2325 let _ = Command::new("git")
2326 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2327 .current_dir(&dir)
2328 .output()
2329 .await;
2330
2331 let _ = Command::new("git")
2333 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2334 .current_dir(&dir)
2335 .output()
2336 .await;
2337 let _ = Command::new("git")
2338 .args(["fetch", "origin"])
2339 .current_dir(&dir)
2340 .output()
2341 .await;
2342
2343 let _ = Command::new("git")
2345 .args(["checkout", "-b", "feature"])
2346 .current_dir(&dir)
2347 .output()
2348 .await;
2349
2350 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2352 let wf_dir = dir.join(".github/workflows");
2353 std::fs::create_dir_all(&wf_dir).unwrap();
2354 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2355 let _ = Command::new("git")
2356 .args(["add", "."])
2357 .current_dir(&dir)
2358 .output()
2359 .await;
2360 let _ = Command::new("git")
2361 .args([
2362 "-c",
2363 "commit.gpgsign=false",
2364 "commit",
2365 "-m",
2366 "add feature and workflow",
2367 ])
2368 .current_dir(&dir)
2369 .output()
2370 .await;
2371
2372 let branch_out = Command::new("git")
2374 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2375 .current_dir(&dir)
2376 .output()
2377 .await;
2378 let default_branch = branch_out
2380 .ok()
2381 .filter(|o| o.status.success())
2382 .map(|o| {
2383 String::from_utf8_lossy(&o.stdout)
2384 .trim()
2385 .strip_prefix("origin/")
2386 .unwrap_or("main")
2387 .to_string()
2388 })
2389 .unwrap_or_else(|| "main".to_string());
2390
2391 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2393 assert!(stripped, "should have stripped workflow files");
2394
2395 let ls_output = Command::new("git")
2397 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2398 .current_dir(&dir)
2399 .output()
2400 .await
2401 .unwrap();
2402 let files = String::from_utf8_lossy(&ls_output.stdout);
2403 assert!(
2404 !files.contains(".github/workflows/ci.yml"),
2405 "workflow file should be removed from commits"
2406 );
2407 assert!(
2408 files.contains("feature.txt"),
2409 "non-workflow files should be preserved"
2410 );
2411
2412 let _ = std::fs::remove_dir_all(&dir);
2414 }
2415
2416 #[tokio::test]
2417 async fn strip_workflow_files_returns_false_when_no_workflows() {
2418 let dir =
2419 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2420 let _ = std::fs::remove_dir_all(&dir);
2421 std::fs::create_dir_all(&dir).unwrap();
2422
2423 let _ = Command::new("git")
2425 .args(["init"])
2426 .current_dir(&dir)
2427 .output()
2428 .await;
2429 let _ = Command::new("git")
2430 .args(["config", "user.email", "test@test.com"])
2431 .current_dir(&dir)
2432 .output()
2433 .await;
2434 let _ = Command::new("git")
2435 .args(["config", "user.name", "Test"])
2436 .current_dir(&dir)
2437 .output()
2438 .await;
2439 std::fs::write(dir.join("init.txt"), "init").unwrap();
2440 let _ = Command::new("git")
2441 .args(["add", "."])
2442 .current_dir(&dir)
2443 .output()
2444 .await;
2445 let _ = Command::new("git")
2446 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2447 .current_dir(&dir)
2448 .output()
2449 .await;
2450
2451 let _ = Command::new("git")
2452 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2453 .current_dir(&dir)
2454 .output()
2455 .await;
2456 let _ = Command::new("git")
2457 .args(["fetch", "origin"])
2458 .current_dir(&dir)
2459 .output()
2460 .await;
2461
2462 let _ = Command::new("git")
2463 .args(["checkout", "-b", "feature2"])
2464 .current_dir(&dir)
2465 .output()
2466 .await;
2467
2468 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2470 let _ = Command::new("git")
2471 .args(["add", "."])
2472 .current_dir(&dir)
2473 .output()
2474 .await;
2475 let _ = Command::new("git")
2476 .args([
2477 "-c",
2478 "commit.gpgsign=false",
2479 "commit",
2480 "-m",
2481 "add feature only",
2482 ])
2483 .current_dir(&dir)
2484 .output()
2485 .await;
2486
2487 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2488 assert!(!stripped, "should not strip when no workflow files exist");
2489
2490 let _ = std::fs::remove_dir_all(&dir);
2492 }
2493
2494 #[test]
2495 fn is_invalid_base_error_detects_base_invalid() {
2496 assert!(is_invalid_base_error(
2497 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2498 ));
2499 assert!(is_invalid_base_error(
2500 "GitHub API POST .../pulls failed (422): Base field is invalid"
2501 ));
2502 }
2503
2504 #[test]
2505 fn is_invalid_base_error_rejects_non_base_errors() {
2506 assert!(!is_invalid_base_error(
2508 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2509 ));
2510 assert!(!is_invalid_base_error(
2512 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2513 ));
2514 assert!(!is_invalid_base_error(
2516 "GitHub API POST .../pulls failed (502): Bad Gateway"
2517 ));
2518 assert!(!is_invalid_base_error(
2520 "GitHub API POST .../pulls failed (404): Not Found"
2521 ));
2522 }
2523}