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
1260fn strip_push_and_pr_creation(text: &str) -> String {
1263 let mut kept_lines: Vec<String> = Vec::new();
1264 for line in text.lines() {
1265 if line.trim().is_empty() {
1266 if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1267 kept_lines.push(String::new());
1268 }
1269 continue;
1270 }
1271
1272 let indent_len = line.len() - line.trim_start().len();
1273 let indent = &line[..indent_len];
1274 let trimmed = line.trim();
1275 let (marker, rest) = match list_marker(trimmed) {
1276 Some((m, r)) => (m, r),
1277 None => ("", trimmed),
1278 };
1279
1280 let kept: Vec<&str> = split_statements(rest)
1281 .into_iter()
1282 .filter(|s| !is_push_or_pr_creation_statement(s))
1283 .collect();
1284 if kept.is_empty() {
1285 continue;
1286 }
1287 kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1288 }
1289 while kept_lines.last().is_some_and(String::is_empty) {
1290 kept_lines.pop();
1291 }
1292 kept_lines.join("\n")
1293}
1294
1295fn split_statements(text: &str) -> Vec<&str> {
1296 static SEP: LazyLock<Regex> =
1297 LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1298 let mut statements = Vec::new();
1299 let mut last = 0;
1300 for cap in SEP.captures_iter(text) {
1301 let full = cap.get(0).expect("full separator match");
1302 let punct = cap.get(1).expect("punctuation capture");
1303 let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1304 let end = if ellipsis { punct.start() } else { punct.end() };
1305 let stmt = text[last..end].trim();
1306 if !stmt.is_empty() {
1307 statements.push(stmt);
1308 }
1309 last = full.end();
1310 }
1311 let rest = text[last..].trim();
1312 if !rest.is_empty() {
1313 statements.push(rest);
1314 }
1315 statements
1316}
1317
1318fn list_marker(line: &str) -> Option<(&str, &str)> {
1319 for prefix in ["- ", "* ", "+ "] {
1320 if let Some(rest) = line.strip_prefix(prefix) {
1321 return Some((prefix, rest));
1322 }
1323 }
1324 let dot = line.find(". ")?;
1325 if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1326 Some((&line[..dot + 2], &line[dot + 2..]))
1327 } else {
1328 None
1329 }
1330}
1331
1332fn is_push_or_pr_creation_statement(text: &str) -> bool {
1333 static PAT: LazyLock<Regex> = LazyLock::new(|| {
1334 Regex::new(concat!(
1335 r"(?i)",
1336 r"\bgit\s+push\b",
1337 r"|\bgh\s+pr\s+create\b",
1338 r"|\bgh\s+pr\s+open\b",
1339 r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1340 r"|\bpush\s+permission\b",
1341 r"|\bcouldn['’]?t\s+push\b",
1342 r"|\bcould\s+not\s+push\b",
1343 r"|\bcan['’]?t\s+push\b",
1344 r"|\bcannot\s+push\b",
1345 r"|\bdidn['’]?t\s+push\b",
1346 r"|\bdid\s+not\s+push\b",
1347 r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1348 r"|\bnot\s+on\s+the\s+remote\b",
1349 r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1350 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1351 r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1352 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1353 ))
1354 .expect("valid push/pr-creation regex")
1355 });
1356 PAT.is_match(text)
1357}
1358
1359fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1360 items
1361 .iter()
1362 .filter_map(|item| {
1363 let cleaned = strip_push_and_pr_creation(item);
1364 (!cleaned.is_empty()).then_some(cleaned)
1365 })
1366 .collect()
1367}
1368
1369#[allow(clippy::too_many_arguments)]
1371fn build_pr_body(
1372 title: &str,
1373 summary: &str,
1374 accomplished: &[String],
1375 remaining: &[String],
1376 files: &[String],
1377 task_id: &str,
1378 agent: &str,
1379 model: Option<&str>,
1380) -> String {
1381 let cleaned_summary = strip_push_and_pr_creation(summary);
1382 let summary_text = if cleaned_summary.is_empty() {
1383 title
1384 } else {
1385 cleaned_summary.as_str()
1386 };
1387 let accomplished = cleaned_pr_items(accomplished);
1388 let remaining = cleaned_pr_items(remaining);
1389
1390 let mut body = format!("## Summary\n\n{summary_text}");
1391
1392 if !accomplished.is_empty() {
1393 body.push_str("\n\n### What was done\n\n");
1394 for item in &accomplished {
1395 body.push_str(&format!("- {item}\n"));
1396 }
1397 }
1398
1399 if !remaining.is_empty() {
1400 body.push_str("\n\n### Remaining\n\n");
1401 for item in &remaining {
1402 body.push_str(&format!("- {item}\n"));
1403 }
1404 }
1405
1406 if !files.is_empty() {
1407 body.push_str("\n\n### Files changed\n\n");
1408 for file in files {
1409 body.push_str(&format!("- `{file}`\n"));
1410 }
1411 }
1412
1413 if !task_id.starts_with("internal:") {
1414 if let Ok(issue_num) = task_id.parse::<u64>() {
1415 body.push_str(&format!("\n\nCloses #{issue_num}"));
1416 }
1417 }
1418
1419 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1420 let task_ref = format_task_ref(task_id);
1421 body.push_str(&format!(
1422 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1423 ));
1424 body
1425}
1426
1427#[allow(clippy::too_many_arguments)]
1436pub async fn create_pr_if_needed(
1437 dir: &Path,
1438 branch: &str,
1439 title: &str,
1440 summary: &str,
1441 accomplished: &[String],
1442 remaining: &[String],
1443 files: &[String],
1444 task_id: &str,
1445 agent: &str,
1446 model: Option<&str>,
1447 repo: &str,
1448 base_branch: &str,
1449) -> PrCreateResult<String> {
1450 let gh = GhHttp::new()?;
1451
1452 match gh.get_pr_number(repo, branch).await {
1454 Ok(Some(pr_number)) => {
1455 tracing::info!(task_id, pr = pr_number, "PR already exists");
1456 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1458 let pr = match gh.get_pr(repo, pr_number).await {
1460 Ok(pr) => pr,
1461 Err(e) => {
1462 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1463 return Err(PrCreateError::ApiError(e));
1464 }
1465 };
1466 return Ok(pr.html_url);
1467 }
1468 Ok(None) => {
1469 }
1471 Err(e) => {
1472 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1473 return Err(PrCreateError::ApiError(e));
1474 }
1475 }
1476
1477 let git_files: Vec<String>;
1479 let effective_files: &[String] = if files.is_empty() {
1480 match tokio::process::Command::new("git")
1481 .args([
1482 "diff",
1483 &format!("origin/{base_branch}...HEAD"),
1484 "--name-only",
1485 ])
1486 .current_dir(dir)
1487 .output()
1488 .await
1489 {
1490 Ok(output) if output.status.success() => {
1491 git_files = String::from_utf8_lossy(&output.stdout)
1492 .lines()
1493 .filter(|l| !l.is_empty())
1494 .map(|l| l.to_string())
1495 .collect();
1496 &git_files
1497 }
1498 _ => files,
1499 }
1500 } else {
1501 files
1502 };
1503
1504 let body = build_pr_body(
1505 title,
1506 summary,
1507 accomplished,
1508 remaining,
1509 effective_files,
1510 task_id,
1511 agent,
1512 model,
1513 );
1514
1515 let pr_title = title;
1517
1518 const MAX_RETRIES: u32 = 3;
1520 let mut last_error: Option<anyhow::Error> = None;
1521 let mut created_url: Option<String> = None;
1522 let mut had_transient_error = false;
1523 let mut effective_base = base_branch.to_string();
1524 let mut tried_api_fallback = false;
1525
1526 for attempt in 1..=MAX_RETRIES {
1527 match gh
1528 .create_pr(repo, pr_title, &body, branch, &effective_base)
1529 .await
1530 {
1531 Ok(u) => {
1532 created_url = Some(u);
1533 break;
1534 }
1535 Err(e) => {
1536 let err_str = format!("{e}");
1537 if is_transient_github_error(&err_str) {
1538 had_transient_error = true;
1539 if attempt < MAX_RETRIES {
1540 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1541 tracing::warn!(
1542 task_id,
1543 attempt,
1544 delay_secs = delay.as_secs(),
1545 error = %e,
1546 "transient GitHub API error during PR creation — retrying"
1547 );
1548 tokio::time::sleep(delay).await;
1549 }
1550 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1551 tried_api_fallback = true;
1554 tracing::warn!(
1555 task_id,
1556 base_branch = %effective_base,
1557 error = %e,
1558 "PR creation failed with invalid base — fetching default branch from GitHub API"
1559 );
1560 match gh.get_default_branch(repo).await {
1561 Ok(api_default) if api_default != effective_base => {
1562 tracing::info!(
1563 task_id,
1564 old_base = %effective_base,
1565 new_base = %api_default,
1566 "retrying PR creation with GitHub API default branch"
1567 );
1568 effective_base = api_default;
1569 continue;
1570 }
1571 Ok(api_default) => {
1572 tracing::warn!(
1573 task_id,
1574 base_branch = %api_default,
1575 "GitHub API default branch matches local — no fallback possible"
1576 );
1577 }
1578 Err(api_err) => {
1579 tracing::warn!(
1580 task_id,
1581 error = %api_err,
1582 "failed to fetch default branch from GitHub API"
1583 );
1584 }
1585 }
1586 }
1587 last_error = Some(e);
1588 }
1589 }
1590 }
1591
1592 let url = match created_url {
1593 Some(u) => u,
1594 None => {
1595 let e = last_error.unwrap_or_else(|| {
1596 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1597 });
1598 let err_str = format!("{e}");
1599 if is_transient_github_error(&err_str) || had_transient_error {
1606 tracing::warn!(
1607 task_id,
1608 error = %e,
1609 "transient GitHub API error after all retries — checking if PR was actually created"
1610 );
1611 match gh.get_pr_number(repo, branch).await {
1612 Ok(Some(pr_number)) => {
1613 tracing::info!(
1614 task_id,
1615 pr = pr_number,
1616 "PR was created despite transient errors — recovering"
1617 );
1618 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1619 .await;
1620 match gh.get_pr(repo, pr_number).await {
1621 Ok(pr) => pr.html_url,
1622 Err(get_err) => {
1623 tracing::warn!(
1624 task_id,
1625 error = %get_err,
1626 "failed to fetch PR URL after transient-error recovery"
1627 );
1628 return Err(PrCreateError::ApiError(e));
1629 }
1630 }
1631 }
1632 Ok(None) => {
1633 tracing::warn!(
1634 task_id,
1635 "PR was not created after all retries — propagating original error"
1636 );
1637 return Err(PrCreateError::ApiError(e));
1638 }
1639 Err(check_err) => {
1640 tracing::warn!(
1641 task_id,
1642 error = %check_err,
1643 "failed to verify PR existence after transient error retries — propagating original error"
1644 );
1645 return Err(PrCreateError::ApiError(e));
1646 }
1647 }
1648 } else {
1649 return Err(PrCreateError::ApiError(e));
1650 }
1651 }
1652 };
1653
1654 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1655
1656 Ok(url)
1657}
1658
1659pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1663 if err_str.contains("HTTP send failed") {
1667 return true;
1668 }
1669 if err_str.contains("GitHub API server error") {
1670 return true;
1671 }
1672
1673 if extract_github_http_status(err_str)
1675 .map(|s| (500..600).contains(&s))
1676 .unwrap_or(false)
1677 {
1678 return true;
1679 }
1680
1681 let lower = err_str.to_lowercase();
1683
1684 if lower.contains("connection reset")
1686 || lower.contains("broken pipe")
1687 || lower.contains("connection refused")
1688 || lower.contains("connection closed")
1689 || lower.contains("transport error")
1690 || lower.contains("failed to connect")
1691 || lower.contains("failed to connect to github.com port")
1692 || lower.contains("curl: (7) failed to connect")
1693 || lower.contains("couldn't connect to server")
1694 || lower.contains("could not connect to server")
1695 {
1696 return true;
1697 }
1698
1699 if lower.contains("timeout") {
1701 return true;
1702 }
1703
1704 if lower.contains("dns error")
1706 || lower.contains("resolve")
1707 || lower.contains("name resolution")
1708 || lower.contains("no such host")
1709 {
1710 return true;
1711 }
1712
1713 if lower.contains("tls handshake")
1715 || lower.contains("certificate")
1716 || lower.contains("ssl error")
1717 {
1718 return true;
1719 }
1720
1721 if lower.contains("network is unreachable")
1723 || lower.contains("host unreachable")
1724 || lower.contains("temporary failure")
1725 {
1726 return true;
1727 }
1728
1729 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1731 return true;
1732 }
1733
1734 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1736 return true;
1737 }
1738
1739 if lower.contains("rate limit") || lower.contains("too many requests") {
1741 return true;
1742 }
1743
1744 false
1745}
1746
1747pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1753 let lower = err_str.to_lowercase();
1754 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1755}
1756
1757pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1765 if !is_transient_github_error(err_str) {
1766 return false;
1767 }
1768
1769 if extract_github_http_status(err_str).is_some() {
1770 return true;
1771 }
1772
1773 let lower = err_str.to_lowercase();
1774 lower.contains("github")
1775 || lower.contains("api.github.com")
1776 || lower.contains("gh api")
1777 || lower.contains("gh pr")
1778 || lower.contains("gh error")
1779 || lower.contains("circuit-breaker")
1784 || lower.contains("circuit breaker")
1785}
1786
1787fn extract_github_http_status(err_str: &str) -> Option<u16> {
1792 let marker = "failed (";
1793 let start = err_str.find(marker)? + marker.len();
1794 let rest = &err_str[start..];
1795 let end = rest.find(')')?;
1796 rest[..end].parse().ok()
1797}
1798
1799async fn append_pr_footer_if_missing(
1804 gh: &GhHttp,
1805 repo: &str,
1806 pr_number: u64,
1807 task_id: &str,
1808 agent: &str,
1809 model: Option<&str>,
1810) {
1811 let pr = match gh.get_pr(repo, pr_number).await {
1812 Ok(pr) => pr,
1813 Err(e) => {
1814 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1815 return;
1816 }
1817 };
1818
1819 let body = pr.body.unwrap_or_default();
1820 if body.contains("via [Orch]") {
1821 return; }
1823
1824 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1825 let task_ref = format_task_ref(task_id);
1826 let footer = format!(
1827 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1828 );
1829 let new_body = format!("{body}{footer}");
1830
1831 if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1832 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1833 } else {
1834 tracing::info!(
1835 task_id,
1836 pr = pr_number,
1837 "appended attribution footer to agent-created PR"
1838 );
1839 }
1840}
1841
1842async fn get_current_branch(dir: &Path) -> String {
1844 let output = Command::new("git")
1845 .args(["branch", "--show-current"])
1846 .current_dir(dir)
1847 .output_with_context()
1848 .await;
1849
1850 match output {
1851 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1852 _ => String::new(),
1853 }
1854}
1855
1856pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1858 let output = Command::new("git")
1860 .args(["status", "--porcelain"])
1861 .current_dir(dir)
1862 .output_with_context()
1863 .await?;
1864
1865 let status_output = String::from_utf8_lossy(&output.stdout);
1866 let count = status_output
1867 .lines()
1868 .filter(|line| {
1869 let prefix = line.get(0..2).unwrap_or("");
1870 prefix.starts_with('M')
1872 || prefix.starts_with('A')
1873 || prefix.starts_with('R')
1874 || prefix.starts_with('C')
1875 || prefix.starts_with('?')
1876 })
1877 .count();
1878
1879 Ok(count)
1880}
1881
1882async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1890 let remote_exists = Command::new("git")
1892 .args(["rev-parse", &format!("origin/{branch}")])
1893 .current_dir(dir)
1894 .output_with_context()
1895 .await
1896 .map(|o| o.status.success())
1897 .unwrap_or(false);
1898
1899 let compare_ref = if remote_exists {
1900 format!("origin/{branch}..HEAD")
1901 } else {
1902 format!("origin/{default_branch}..HEAD")
1905 };
1906
1907 tracing::debug!(
1908 branch,
1909 compare_ref,
1910 remote_exists,
1911 "checking unpushed commits"
1912 );
1913
1914 let output = Command::new("git")
1915 .args(["log", &compare_ref, "--oneline"])
1916 .current_dir(dir)
1917 .output_with_context()
1918 .await;
1919
1920 let has_unpushed = match output {
1921 Ok(o) if o.status.success() => {
1922 let out = String::from_utf8_lossy(&o.stdout);
1923 let has = !out.trim().is_empty();
1924 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1925 has
1926 }
1927 _ => {
1928 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1929 true
1932 }
1933 };
1934
1935 (remote_exists, has_unpushed)
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940 use super::*;
1941
1942 #[test]
1943 fn pr_create_error_display() {
1944 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1945 assert_eq!(format!("{}", err), "GitHub API error: test error");
1946 }
1947
1948 #[test]
1949 fn pr_create_error_from_anyhow() {
1950 let anyhow_err = anyhow::anyhow!("source error");
1951 let pr_err: PrCreateError = anyhow_err.into();
1952 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1953 }
1954
1955 #[test]
1956 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1957 let env = build_git_auth_env_for_token(None);
1960
1961 let has_ssh_rule = env
1964 .iter()
1965 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
1966 assert!(
1967 has_ssh_rule,
1968 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
1974 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
1976
1977 let has_token = env
1979 .iter()
1980 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
1981 assert!(has_token, "expected token in auth URL, got: {env:?}");
1982 let has_https_rule = env
1984 .iter()
1985 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
1986 assert!(
1987 has_https_rule,
1988 "expected HTTPS insteadOf rule, got: {env:?}"
1989 );
1990 let has_ssh_rule = env
1992 .iter()
1993 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
1994 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
1995 }
1996
1997 #[test]
1998 fn find_stash_ref_by_hash_returns_matching_reference() {
1999 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2000
2001 assert_eq!(
2002 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2003 Some("stash@{1}".to_string())
2004 );
2005 assert_eq!(
2006 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2007 Some("stash@{0}".to_string())
2008 );
2009 }
2010
2011 #[test]
2012 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2013 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2014
2015 assert_eq!(
2016 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2017 None
2018 );
2019 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2020 }
2021
2022 #[test]
2023 fn push_needs_rebase_detects_common_rejections() {
2024 assert!(push_needs_rebase(
2025 "! [rejected] branch -> branch (fetch first)"
2026 ));
2027 assert!(push_needs_rebase("non-fast-forward update was rejected"));
2028 assert!(push_needs_rebase(
2029 "push rejected because the remote contains work"
2030 ));
2031 }
2032
2033 #[test]
2034 fn is_transient_network_error_detects_lfs_locks_timeout() {
2035 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";
2036 assert!(is_transient_network_error(lfs_err));
2037 assert!(is_transient_network_error(
2038 "dial tcp 140.82.112.4:443: connect: connection refused"
2039 ));
2040 assert!(is_transient_network_error(
2041 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2042 ));
2043 }
2044
2045 #[test]
2046 fn is_transient_network_error_rejects_ref_rejections() {
2047 assert!(!is_transient_network_error(
2048 "! [rejected] branch -> branch (fetch first)"
2049 ));
2050 assert!(!is_transient_network_error(
2051 "remote: error: GH006: Protected branch update failed"
2052 ));
2053 }
2054
2055 #[test]
2056 fn sanitize_push_error_strips_url_credentials() {
2057 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";
2059 let sanitized = sanitize_push_error(raw);
2060 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2061 assert!(sanitized.contains("://***@github.com"));
2062 assert!(sanitized.contains("i/o timeout"));
2063 }
2064
2065 #[test]
2066 fn extract_github_http_status_parses_ghhttp_error_format() {
2067 let err =
2068 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2069 assert_eq!(extract_github_http_status(err), Some(502));
2070
2071 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2072 assert_eq!(extract_github_http_status(err500), Some(500));
2073
2074 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2075 assert_eq!(extract_github_http_status(err422), Some(422));
2076
2077 let not_github = "some other error";
2078 assert_eq!(extract_github_http_status(not_github), None);
2079 }
2080
2081 #[test]
2082 fn is_transient_github_error_matches_5xx_only() {
2083 let err502 =
2084 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2085 assert!(is_transient_github_error(err502));
2086
2087 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2088 assert!(is_transient_github_error(err503));
2089
2090 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2091 assert!(!is_transient_github_error(err422));
2092
2093 let err404 =
2094 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2095 assert!(!is_transient_github_error(err404));
2096 }
2097
2098 #[test]
2099 fn is_transient_github_error_matches_connection_errors() {
2100 assert!(is_transient_github_error("connection refused"));
2102 assert!(is_transient_github_error("Connection reset by peer"));
2103 assert!(is_transient_github_error("broken pipe"));
2104 assert!(is_transient_github_error("connection closed unexpectedly"));
2105 assert!(is_transient_github_error(
2106 "transport error: connection lost"
2107 ));
2108
2109 assert!(is_transient_github_error(
2111 "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"
2112 ));
2113 assert!(is_transient_github_error(
2114 "Failed to connect to github.com port 443: Connection timed out"
2115 ));
2116 assert!(is_transient_github_error(
2117 "Failed to connect to github.com port 443: Connection refused"
2118 ));
2119 assert!(is_transient_github_error(
2120 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2121 ));
2122 assert!(is_transient_github_error(
2123 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2124 ));
2125 }
2126
2127 #[test]
2128 fn is_transient_github_error_matches_dns_errors() {
2129 assert!(is_transient_github_error("dns error: failed to lookup"));
2131 assert!(is_transient_github_error("failed to resolve hostname"));
2132 assert!(is_transient_github_error("name resolution failed"));
2133 assert!(is_transient_github_error("no such host: api.github.com"));
2134 }
2135
2136 #[test]
2137 fn is_transient_github_error_matches_tls_errors() {
2138 assert!(is_transient_github_error("tls handshake failed"));
2140 assert!(is_transient_github_error("certificate verification failed"));
2141 assert!(is_transient_github_error("ssl error: unknown ca"));
2142 }
2143
2144 #[test]
2145 fn is_transient_github_error_matches_network_unreachable() {
2146 assert!(is_transient_github_error("network is unreachable"));
2148 assert!(is_transient_github_error("host unreachable"));
2149 assert!(is_transient_github_error(
2150 "temporary failure in name resolution"
2151 ));
2152 }
2153
2154 #[test]
2155 fn is_transient_github_error_matches_eof_errors() {
2156 assert!(is_transient_github_error("unexpected eof while reading"));
2158 assert!(is_transient_github_error("unexpected end of file"));
2159 }
2160
2161 #[test]
2162 fn is_transient_github_error_matches_rate_limit() {
2163 assert!(is_transient_github_error("rate limit exceeded"));
2165 assert!(is_transient_github_error("429 Too Many Requests"));
2166 assert!(is_transient_github_error("too many requests, retry later"));
2167 }
2168
2169 #[test]
2170 fn is_transient_github_error_matches_timeout() {
2171 assert!(is_transient_github_error("operation timeout"));
2173 assert!(is_transient_github_error("request timeout"));
2174 }
2175
2176 #[test]
2177 fn is_transient_github_error_rejects_non_transient() {
2178 assert!(!is_transient_github_error("permission denied"));
2180 assert!(!is_transient_github_error("file not found"));
2181 assert!(!is_transient_github_error("invalid argument"));
2182 assert!(!is_transient_github_error("authentication failed"));
2183 assert!(!is_transient_github_error("bad credentials"));
2184 }
2185
2186 #[test]
2187 fn is_transient_github_api_error_requires_github_context() {
2188 assert!(is_transient_github_api_error(
2189 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2190 ));
2191 assert!(is_transient_github_api_error(
2192 "gh error: transport error: connection reset by peer"
2193 ));
2194 assert!(is_transient_github_api_error(
2195 "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"
2196 ));
2197 assert!(!is_transient_github_api_error(
2198 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2199 ));
2200 assert!(!is_transient_github_api_error(
2201 "agent stream failed: failed to connect to local relay"
2202 ));
2203 }
2204
2205 #[test]
2206 fn is_transient_github_api_error_matches_circuit_breaker() {
2207 assert!(is_transient_github_api_error(
2209 "GitHub API transient 5xx circuit-breaker active for 240s"
2210 ));
2211 assert!(is_transient_github_api_error(
2213 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2214 ));
2215 assert!(is_transient_github_api_error(
2218 "create PR failed: circuit-breaker"
2219 ));
2220 assert!(is_transient_github_api_error(
2222 "create PR failed: circuit breaker tripped"
2223 ));
2224 }
2225
2226 #[tokio::test]
2227 async fn auto_commit_unstages_files_on_commit_failure() {
2228 let dir =
2230 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2231 let _ = std::fs::remove_dir_all(&dir);
2232 std::fs::create_dir_all(&dir).unwrap();
2233
2234 let _ = Command::new("git")
2236 .args(["init"])
2237 .current_dir(&dir)
2238 .output()
2239 .await;
2240 let _ = Command::new("git")
2241 .args(["config", "user.email", "test@test.com"])
2242 .current_dir(&dir)
2243 .output()
2244 .await;
2245 let _ = Command::new("git")
2246 .args(["config", "user.name", "Test"])
2247 .current_dir(&dir)
2248 .output()
2249 .await;
2250 std::fs::write(dir.join("init.txt"), "init").unwrap();
2252 let _ = Command::new("git")
2253 .args(["add", "init.txt"])
2254 .current_dir(&dir)
2255 .output()
2256 .await;
2257 let _ = Command::new("git")
2258 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2259 .current_dir(&dir)
2260 .output()
2261 .await;
2262
2263 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2265
2266 assert!(has_changes(&dir).await);
2268
2269 let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2274 assert!(result.unwrap());
2275
2276 assert!(!has_changes(&dir).await);
2278
2279 let _ = std::fs::remove_dir_all(&dir);
2281 }
2282
2283 #[tokio::test]
2284 async fn auto_commit_ignores_repo_signing_config() {
2285 let dir =
2288 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2289 let _ = std::fs::remove_dir_all(&dir);
2290 std::fs::create_dir_all(&dir).unwrap();
2291
2292 for args in [
2293 vec!["init"],
2294 vec!["config", "user.email", "test@test.com"],
2295 vec!["config", "user.name", "Test"],
2296 vec!["config", "commit.gpgsign", "true"],
2297 vec!["config", "gpg.program", "/usr/bin/false"],
2298 ] {
2299 let _ = Command::new("git")
2300 .args(&args)
2301 .current_dir(&dir)
2302 .output()
2303 .await;
2304 }
2305 std::fs::write(dir.join("init.txt"), "init").unwrap();
2306 let _ = Command::new("git")
2307 .args(["add", "init.txt"])
2308 .current_dir(&dir)
2309 .output()
2310 .await;
2311 let _ = Command::new("git")
2312 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2313 .current_dir(&dir)
2314 .output()
2315 .await;
2316
2317 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2318 assert!(has_changes(&dir).await);
2319
2320 let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2321 assert!(result.unwrap());
2322 assert!(!has_changes(&dir).await);
2323
2324 let _ = std::fs::remove_dir_all(&dir);
2325 }
2326
2327 #[tokio::test]
2328 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2329 let dir =
2331 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2332 let _ = std::fs::remove_dir_all(&dir);
2333 std::fs::create_dir_all(&dir).unwrap();
2334
2335 let _ = Command::new("git")
2337 .args(["init"])
2338 .current_dir(&dir)
2339 .output()
2340 .await;
2341 let _ = Command::new("git")
2342 .args(["config", "user.email", "test@test.com"])
2343 .current_dir(&dir)
2344 .output()
2345 .await;
2346 let _ = Command::new("git")
2347 .args(["config", "user.name", "Test"])
2348 .current_dir(&dir)
2349 .output()
2350 .await;
2351 std::fs::write(dir.join("init.txt"), "init").unwrap();
2353 let _ = Command::new("git")
2354 .args(["add", "init.txt"])
2355 .current_dir(&dir)
2356 .output()
2357 .await;
2358 let _ = Command::new("git")
2359 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2360 .current_dir(&dir)
2361 .output()
2362 .await;
2363
2364 let hooks_dir = dir.join(".git/hooks");
2366 std::fs::create_dir_all(&hooks_dir).unwrap();
2367 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2368 #[cfg(unix)]
2369 {
2370 use std::os::unix::fs::PermissionsExt;
2371 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2372 .unwrap()
2373 .permissions();
2374 perms.set_mode(0o755);
2375 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2376 }
2377
2378 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2380
2381 assert!(has_changes(&dir).await);
2383
2384 let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2386 assert!(!result.unwrap());
2387
2388 assert!(has_changes(&dir).await);
2391
2392 let cached = Command::new("git")
2394 .args(["diff", "--cached", "--quiet"])
2395 .current_dir(&dir)
2396 .status()
2397 .await;
2398 assert!(
2399 cached.map(|s| s.success()).unwrap_or(false),
2400 "files should be unstaged after commit failure"
2401 );
2402
2403 let _ = std::fs::remove_dir_all(&dir);
2405 }
2406
2407 #[test]
2408 fn is_workflow_scope_error_detects_backtick_format() {
2409 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2410 assert!(is_workflow_scope_error(err));
2411 }
2412
2413 #[test]
2414 fn is_workflow_scope_error_detects_single_quote_format() {
2415 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2416 assert!(is_workflow_scope_error(err));
2417 }
2418
2419 #[test]
2420 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2421 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2422 assert!(is_workflow_scope_error(err));
2423 }
2424
2425 #[test]
2426 fn is_workflow_scope_error_rejects_unrelated_errors() {
2427 assert!(!is_workflow_scope_error("non-fast-forward"));
2428 assert!(!is_workflow_scope_error("permission denied"));
2429 assert!(!is_workflow_scope_error("authentication failed"));
2430 assert!(!is_workflow_scope_error("rejected"));
2431 }
2432
2433 #[tokio::test]
2434 async fn strip_workflow_files_removes_workflow_from_commits() {
2435 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2437 let _ = std::fs::remove_dir_all(&dir);
2438 std::fs::create_dir_all(&dir).unwrap();
2439
2440 let _ = Command::new("git")
2442 .args(["init"])
2443 .current_dir(&dir)
2444 .output()
2445 .await;
2446 let _ = Command::new("git")
2447 .args(["config", "user.email", "test@test.com"])
2448 .current_dir(&dir)
2449 .output()
2450 .await;
2451 let _ = Command::new("git")
2452 .args(["config", "user.name", "Test"])
2453 .current_dir(&dir)
2454 .output()
2455 .await;
2456 std::fs::write(dir.join("init.txt"), "init").unwrap();
2457 let _ = Command::new("git")
2458 .args(["add", "."])
2459 .current_dir(&dir)
2460 .output()
2461 .await;
2462 let _ = Command::new("git")
2463 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2464 .current_dir(&dir)
2465 .output()
2466 .await;
2467
2468 let _ = Command::new("git")
2470 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2471 .current_dir(&dir)
2472 .output()
2473 .await;
2474 let _ = Command::new("git")
2475 .args(["fetch", "origin"])
2476 .current_dir(&dir)
2477 .output()
2478 .await;
2479
2480 let _ = Command::new("git")
2482 .args(["checkout", "-b", "feature"])
2483 .current_dir(&dir)
2484 .output()
2485 .await;
2486
2487 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2489 let wf_dir = dir.join(".github/workflows");
2490 std::fs::create_dir_all(&wf_dir).unwrap();
2491 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2492 let _ = Command::new("git")
2493 .args(["add", "."])
2494 .current_dir(&dir)
2495 .output()
2496 .await;
2497 let _ = Command::new("git")
2498 .args([
2499 "-c",
2500 "commit.gpgsign=false",
2501 "commit",
2502 "-m",
2503 "add feature and workflow",
2504 ])
2505 .current_dir(&dir)
2506 .output()
2507 .await;
2508
2509 let branch_out = Command::new("git")
2511 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2512 .current_dir(&dir)
2513 .output()
2514 .await;
2515 let default_branch = branch_out
2517 .ok()
2518 .filter(|o| o.status.success())
2519 .map(|o| {
2520 String::from_utf8_lossy(&o.stdout)
2521 .trim()
2522 .strip_prefix("origin/")
2523 .unwrap_or("main")
2524 .to_string()
2525 })
2526 .unwrap_or_else(|| "main".to_string());
2527
2528 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2530 assert!(stripped, "should have stripped workflow files");
2531
2532 let ls_output = Command::new("git")
2534 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2535 .current_dir(&dir)
2536 .output()
2537 .await
2538 .unwrap();
2539 let files = String::from_utf8_lossy(&ls_output.stdout);
2540 assert!(
2541 !files.contains(".github/workflows/ci.yml"),
2542 "workflow file should be removed from commits"
2543 );
2544 assert!(
2545 files.contains("feature.txt"),
2546 "non-workflow files should be preserved"
2547 );
2548
2549 let _ = std::fs::remove_dir_all(&dir);
2551 }
2552
2553 #[tokio::test]
2554 async fn strip_workflow_files_returns_false_when_no_workflows() {
2555 let dir =
2556 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2557 let _ = std::fs::remove_dir_all(&dir);
2558 std::fs::create_dir_all(&dir).unwrap();
2559
2560 let _ = Command::new("git")
2562 .args(["init"])
2563 .current_dir(&dir)
2564 .output()
2565 .await;
2566 let _ = Command::new("git")
2567 .args(["config", "user.email", "test@test.com"])
2568 .current_dir(&dir)
2569 .output()
2570 .await;
2571 let _ = Command::new("git")
2572 .args(["config", "user.name", "Test"])
2573 .current_dir(&dir)
2574 .output()
2575 .await;
2576 std::fs::write(dir.join("init.txt"), "init").unwrap();
2577 let _ = Command::new("git")
2578 .args(["add", "."])
2579 .current_dir(&dir)
2580 .output()
2581 .await;
2582 let _ = Command::new("git")
2583 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2584 .current_dir(&dir)
2585 .output()
2586 .await;
2587
2588 let _ = Command::new("git")
2589 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2590 .current_dir(&dir)
2591 .output()
2592 .await;
2593 let _ = Command::new("git")
2594 .args(["fetch", "origin"])
2595 .current_dir(&dir)
2596 .output()
2597 .await;
2598
2599 let _ = Command::new("git")
2600 .args(["checkout", "-b", "feature2"])
2601 .current_dir(&dir)
2602 .output()
2603 .await;
2604
2605 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2607 let _ = Command::new("git")
2608 .args(["add", "."])
2609 .current_dir(&dir)
2610 .output()
2611 .await;
2612 let _ = Command::new("git")
2613 .args([
2614 "-c",
2615 "commit.gpgsign=false",
2616 "commit",
2617 "-m",
2618 "add feature only",
2619 ])
2620 .current_dir(&dir)
2621 .output()
2622 .await;
2623
2624 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2625 assert!(!stripped, "should not strip when no workflow files exist");
2626
2627 let _ = std::fs::remove_dir_all(&dir);
2629 }
2630
2631 #[test]
2632 fn is_invalid_base_error_detects_base_invalid() {
2633 assert!(is_invalid_base_error(
2634 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2635 ));
2636 assert!(is_invalid_base_error(
2637 "GitHub API POST .../pulls failed (422): Base field is invalid"
2638 ));
2639 }
2640
2641 #[test]
2642 fn is_invalid_base_error_rejects_non_base_errors() {
2643 assert!(!is_invalid_base_error(
2645 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2646 ));
2647 assert!(!is_invalid_base_error(
2649 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2650 ));
2651 assert!(!is_invalid_base_error(
2653 "GitHub API POST .../pulls failed (502): Bad Gateway"
2654 ));
2655 assert!(!is_invalid_base_error(
2657 "GitHub API POST .../pulls failed (404): Not Found"
2658 ));
2659 }
2660
2661 fn pr_summary(body: &str) -> &str {
2662 let rest = body
2663 .strip_prefix("## Summary\n\n")
2664 .expect("PR body starts with ## Summary");
2665 rest.split("\n\n").next().unwrap()
2666 }
2667
2668 fn assert_no_push_or_pr_creation(body: &str) {
2669 let lower = body.to_ascii_lowercase();
2670 for needle in [
2671 "git push",
2672 "gh pr create",
2673 "did not push",
2674 "couldn't push",
2675 "could not push",
2676 "push was denied",
2677 "push was blocked",
2678 "push permission",
2679 "nothing is on the remote",
2680 "open the pr",
2681 ] {
2682 assert!(
2683 !lower.contains(needle),
2684 "PR body still contains {needle:?}:\n{body}"
2685 );
2686 }
2687 }
2688
2689 #[test]
2690 fn build_pr_body_strips_push_and_pr_creation_statements() {
2691 let examples = [
2692 (
2693 concat!(
2694 "Added the lesson list page. ",
2695 "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2696 "Run `git push -u origin HEAD`, then `gh pr create`.",
2697 ),
2698 "Added the lesson list page.",
2699 ),
2700 (
2701 "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2702 "It is committed locally",
2703 ),
2704 (
2705 concat!(
2706 "Fixed the quiz scoring. ",
2707 "I couldn't push it because the push permission was denied.",
2708 ),
2709 "Fixed the quiz scoring.",
2710 ),
2711 ];
2712 for (summary, work) in examples {
2713 let body = build_pr_body(
2714 "Fix the widget",
2715 summary,
2716 &[
2717 "Wrote the lesson list".into(),
2718 "Could not push because the push permission was denied".into(),
2719 ],
2720 &["Run `git push -u origin HEAD`".into()],
2721 &[],
2722 "16",
2723 "claude",
2724 None,
2725 );
2726 assert_eq!(pr_summary(&body), work);
2727 assert!(body.contains("Wrote the lesson list"));
2728 assert!(!body.contains("### Remaining"));
2729 assert_no_push_or_pr_creation(&body);
2730 }
2731 }
2732
2733 #[test]
2734 fn build_pr_body_keeps_summary_without_push_text() {
2735 let summary = "Added retry logic for GitHub 5xx errors.";
2736 let accomplished = vec!["Extracted the PR body builder".into()];
2737 let remaining = vec!["Watch CI after merge".into()];
2738 let body = build_pr_body(
2739 "Fix the widget",
2740 summary,
2741 &accomplished,
2742 &remaining,
2743 &["src/engine/runner/git_ops.rs".into()],
2744 "16",
2745 "claude",
2746 Some("sonnet"),
2747 );
2748 assert_eq!(pr_summary(&body), summary);
2749 assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2750 assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2751 assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2752 assert!(body.contains(" using `sonnet`"));
2753 }
2754}