From ee1502f7935e1f18c4de789964783f8ada3850c3 Mon Sep 17 00:00:00 2001
From: "fabro-sh-0530[bot]"
<281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Date: Wed, 27 May 2026 20:14:56 -0400
Subject: [PATCH] Add automation run materialization core and shared run
creation helper (#441)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Automation-triggered runs need to share the same run creation pipeline
as `POST /runs`. This PR lays the core infrastructure: a
`create_run_from_manifest` helper that the HTTP handler and the upcoming
automation scheduler can both call, plus a `AutomationRunMaterializer`
trait with a production implementation that clones a GitHub repo and
builds a `RunManifest` from it.
### Plan Summary
- Extract the body of `handler/runs.rs::create_run` into a crate-private
`create_run_from_manifest(state, CreateRunFromManifestRequest)` helper;
`POST /runs` calls it with `automation: None`, preserving existing
behavior.
- Add `AutomationRunMaterializeInput/Materialized/Error` types and the
`AutomationRunMaterializer` trait (`automation_materializer.rs`).
- Implement `ProductionAutomationRunMaterializer`: validates
`owner/repo` slug, shallow-clones via `tokio::process::Command` argv
(never shell strings), sets `GIT_TERMINAL_PROMPT=0`, enforces
per-operation timeouts, redacts credentials from error text, resolves
the workflow with `fabro_config::project::WorkflowLocation::resolve`,
and builds a `RunManifest` via `fabro_manifest::build_run_manifest`.
- Add `TestAutomationRunMaterializer` (gated on `test` or
`test-support`) for fake injection in route tests without network
access.
- Wire the materializer override into `AppState` and `AppStateConfig`
behind `#[cfg(any(test, feature = "test-support"))]`; expose via
`TestAppStateBuilder::automation_materializer`.
- Move `async-trait` from `[dev-dependencies]` to `[dependencies]` in
`fabro-server` since the trait is now in production code.
## What changed and why
**`automation_materializer.rs` (new)** — Core of this PR. The
`GitCommandPlan` builder keeps all git invocations as argv slices so
there is no shell injection surface. Credentials are injected
exclusively via `GIT_CONFIG_VALUE_0` (the `extraheader` mechanism),
never embedded in the clone URL, so they cannot appear in run metadata
or error messages. The `redact_git_output` function scrubs the raw
token, the Base64-encoded form, and the full `AUTHORIZATION` header
value from any error string before it surfaces.
**`create_run_from_manifest`** — The extracted helper accepts an
optional `AutomationRef` which is forwarded into
`create_input.automation` so the store can persist automation provenance
on the run. The `POST /runs` code path passes `None`, leaving existing
API behavior identical.
**Test injection** — `TestAutomationRunMaterializer` captures every
`AutomationRunMaterializeInput` it receives and returns a
caller-controlled `Result`, letting route tests assert what inputs the
scheduler would pass without touching GitHub.
```mermaid
flowchart TB
A["POST /runs\n(HTTP handler)"] -->|automation: None| H["create_run_from_manifest"]
S["Automation scheduler\n(future issue)"] -->|automation: Some(ref)| H
H --> DB[(Run store)]
M["AutomationRunMaterializer\n(trait)"] -->|produces RunManifest| S
M -- production --> P["ProductionAutomationRunMaterializer\n(git clone → manifest build)"]
M -- test --> T["TestAutomationRunMaterializer\n(captures input, returns fixture)"]
```
### Fabro Details
Ran 8 stages in 72m 56s for $36.55
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 2s | – | 0 |
| preflight_compile | 2m 12s | – | 0 |
| preflight_lint | 2m 27s | – | 0 |
| implement | 30m 41s | $23.10 | 0 |
| simplify_opus | 19m 19s | $9.12 | 0 |
| simplify_gpt | 7m 53s | $4.33 | 0 |
| verify | 9m 20s | – | 0 |
| **Total** | **72m 56s** | **$36.55** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14
edges)
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
lib/crates/fabro-api/build.rs | 1 +
lib/crates/fabro-api/src/lib.rs | 53 +-
.../fabro-api/tests/run_summary_round_trip.rs | 7 +-
lib/crates/fabro-server/Cargo.toml | 2 +-
.../src/automation_materializer.rs | 797 ++++++++++++++++++
lib/crates/fabro-server/src/lib.rs | 5 +
lib/crates/fabro-server/src/serve.rs | 2 +
lib/crates/fabro-server/src/server.rs | 65 +-
.../fabro-server/src/server/handler/mod.rs | 2 +-
.../fabro-server/src/server/handler/runs.rs | 54 +-
lib/crates/fabro-server/src/server/tests.rs | 156 ++++
lib/crates/fabro-server/src/test_support.rs | 10 +
12 files changed, 1104 insertions(+), 50 deletions(-)
create mode 100644 lib/crates/fabro-server/src/automation_materializer.rs
diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs
index 185435b66..d7ddfbba2 100644
--- a/lib/crates/fabro-api/build.rs
+++ b/lib/crates/fabro-api/build.rs
@@ -632,6 +632,7 @@ fn main() {
("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]),
("AskFabro", "fabro_types::AskFabro", &[]),
("Automation", "fabro_automation::Automation", &[]),
+ ("AutomationRef", "fabro_types::AutomationRef", &[]),
("AutomationTarget", "fabro_automation::AutomationTarget", &[
]),
(
diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs
index a3dcd6867..6c0b06906 100644
--- a/lib/crates/fabro-api/src/lib.rs
+++ b/lib/crates/fabro-api/src/lib.rs
@@ -40,32 +40,33 @@ pub mod types {
pub use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro,
- AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, CreateVariableRequest,
- DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory,
- FailureDetail, FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
- IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
- IntegrationStatus, InterviewOption, InterviewQuestionRecord, McpServerProjection,
- McpServerStatus, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord,
- PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse,
- PendingInterviewRecord, PermissionLevel, PreRunPushOutcome, Principal, PullRequest,
- PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason,
- PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run,
- RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind,
- RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,
- RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,
- RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails,
- SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
- SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
- SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
- SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
- SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
- SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
- StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
- StageContextWindowStaleness, StageContextWindowUnavailableReason,
- StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection,
- StageState, SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
- SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest,
- UserPrincipal, Variable, VariableListResponse, WorkflowSettings,
+ AuthMethod, AutomationRef, BilledTokenCounts, CommandTermination, Conclusion,
+ CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail,
+ FailureCategory, FailureDetail, FailureSignature, GitContext, IdpIdentity,
+ IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus,
+ IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord,
+ McpServerProjection, McpServerStatus, PairId, PairMessageId, PairMessageRecord,
+ PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget,
+ PairTranscriptEntry, PairTranscriptResponse, PendingInterviewRecord, PermissionLevel,
+ PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
+ PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
+ QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance,
+ RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,
+ RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
+ RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime,
+ RunServerProvenance, RunSize, SandboxDetails, SandboxInfo, SandboxListMeta,
+ SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode,
+ SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxService,
+ SandboxServiceListResponse, SandboxState, SandboxTimestamps, SecretMetadata, SecretType,
+ ServerSettings, SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus,
+ SessionSummary, SessionTurn, SkillsProjection, StageCompletion, StageContextWindow,
+ StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
+ StageContextWindowProjection, StageContextWindowStaleness,
+ StageContextWindowUnavailableReason, StageContextWindowWarning, StageHandler,
+ StageModelUsage, StageOutcome, StageProjection, StageState, SubAgentProjection,
+ SubAgentStatus, SystemActorKind, SystemIntegrationStatus, SystemIntegrationsResponse,
+ TodoListProjection, TurnId, UpdateVariableRequest, UserPrincipal, Variable,
+ VariableListResponse, WorkflowSettings,
};
pub use crate::generated::types::*;
diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs
index 0c5a70fb5..798448941 100644
--- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs
+++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs
@@ -3,9 +3,9 @@ use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_api::types::{
- RepositoryRef as ApiRepositoryRef, Run as ApiRun, RunApproval as ApiRunApproval,
- RunApprovalState as ApiRunApprovalState, RunRunnableSource as ApiRunRunnableSource,
- RunSize as ApiRunSize,
+ AutomationRef as ApiAutomationRef, RepositoryRef as ApiRepositoryRef, Run as ApiRun,
+ RunApproval as ApiRunApproval, RunApprovalState as ApiRunApprovalState,
+ RunRunnableSource as ApiRunRunnableSource, RunSize as ApiRunSize,
};
use fabro_types::status::{RunStatus, SuccessReason};
use fabro_types::{
@@ -24,6 +24,7 @@ fn run_summary_reuses_domain_types() {
assert_same_type::();
assert_same_type::();
assert_same_type::();
+ assert_same_type::();
}
#[test]
diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml
index 89611b971..2b9568ce1 100644
--- a/lib/crates/fabro-server/Cargo.toml
+++ b/lib/crates/fabro-server/Cargo.toml
@@ -69,6 +69,7 @@ serde.workspace = true
serde_json.workspace = true
serde_yaml = "0.9"
anyhow.workspace = true
+async-trait.workspace = true
clap.workspace = true
toml.workspace = true
toml_edit.workspace = true
@@ -110,7 +111,6 @@ http-body-util = "0.1"
httpmock = "0.8"
serde_yaml = "0.9"
tracing-subscriber.workspace = true
-async-trait.workspace = true
tokio-util.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
diff --git a/lib/crates/fabro-server/src/automation_materializer.rs b/lib/crates/fabro-server/src/automation_materializer.rs
new file mode 100644
index 000000000..74c9c2677
--- /dev/null
+++ b/lib/crates/fabro-server/src/automation_materializer.rs
@@ -0,0 +1,797 @@
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use anyhow::Context as _;
+use async_trait::async_trait;
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
+use fabro_api::types::RunManifest;
+use fabro_automation::{AutomationId, AutomationTarget};
+use fabro_manifest::ManifestBuildInput;
+use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId};
+use fabro_util::error::collect_chain;
+use tokio::process::Command;
+use tokio::{fs, task, time};
+
+const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(2);
+const GIT_FETCH_TIMEOUT: Duration = Duration::from_mins(1);
+const GIT_CHECKOUT_TIMEOUT: Duration = Duration::from_secs(30);
+const GIT_REV_PARSE_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct AutomationRunMaterializeInput {
+ pub automation_id: AutomationId,
+ pub target: AutomationTarget,
+ pub run_id: RunId,
+ pub user_settings_path: PathBuf,
+ pub temp_root: PathBuf,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct AutomationRunMaterialized {
+ pub manifest: RunManifest,
+ pub submitted_manifest_bytes: Vec,
+}
+
+#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
+pub(crate) enum AutomationRunMaterializeError {
+ #[error("invalid automation target: {0}")]
+ InvalidTarget(String),
+ #[error("failed to clone automation repository: {0}")]
+ CloneFailed(String),
+ #[error("failed to resolve automation workflow: {0}")]
+ WorkflowNotFound(String),
+ #[error("failed to build run manifest: {0}")]
+ Manifest(String),
+}
+
+#[async_trait]
+pub(crate) trait AutomationRunMaterializer: Send + Sync {
+ async fn materialize(
+ &self,
+ input: AutomationRunMaterializeInput,
+ ) -> Result;
+}
+
+#[derive(Clone)]
+pub(crate) struct ProductionAutomationRunMaterializer {
+ github_credentials: Option,
+ github_api_base_url: String,
+ http_client: Option,
+}
+
+impl ProductionAutomationRunMaterializer {
+ pub(crate) fn new(
+ github_credentials: Option,
+ github_api_base_url: String,
+ http_client: Option,
+ ) -> Self {
+ Self {
+ github_credentials,
+ github_api_base_url,
+ http_client,
+ }
+ }
+}
+
+#[async_trait]
+impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
+ async fn materialize(
+ &self,
+ input: AutomationRunMaterializeInput,
+ ) -> Result {
+ let repo = parse_github_repository_slug(&input.target.repository)?;
+ fs::create_dir_all(&input.temp_root).await.map_err(|err| {
+ AutomationRunMaterializeError::CloneFailed(format!(
+ "failed to create temp root {}: {err}",
+ input.temp_root.display()
+ ))
+ })?;
+ let temp_dir = tempfile::Builder::new()
+ .prefix(&format!(
+ "automation-{}-{}-",
+ input.automation_id.as_str(),
+ input.run_id
+ ))
+ .tempdir_in(&input.temp_root)
+ .map_err(|err| {
+ AutomationRunMaterializeError::CloneFailed(format!(
+ "failed to create per-run temp directory under {}: {err}",
+ input.temp_root.display()
+ ))
+ })?;
+ let checkout_dir = temp_dir.path().join("repo");
+ let clone_url = github_clone_url(&repo);
+ let auth = resolve_git_auth_config(
+ self.github_credentials.as_ref(),
+ &repo,
+ &self.github_api_base_url,
+ self.http_client.clone(),
+ )
+ .await
+ .map_err(|err| {
+ AutomationRunMaterializeError::CloneFailed(render_error_chain(err.as_ref()))
+ })?;
+
+ run_git_plan(build_clone_plan(&clone_url, &checkout_dir, auth.as_ref())).await?;
+ run_git_plan(build_fetch_ref_plan(
+ &clone_url,
+ &checkout_dir,
+ &input.target.ref_selector,
+ auth.as_ref(),
+ ))
+ .await?;
+ run_git_plan(build_checkout_ref_plan(&checkout_dir)).await?;
+ let checked_out_sha = run_git_plan(build_rev_parse_head_plan(&checkout_dir))
+ .await
+ .map(|stdout| String::from_utf8_lossy(&stdout).trim().to_string())?;
+
+ let manifest_input = ManifestFromCheckoutInput {
+ input,
+ checkout_dir,
+ repo,
+ checked_out_sha: Some(checked_out_sha),
+ };
+ task::spawn_blocking(move || build_manifest_from_checkout(manifest_input))
+ .await
+ .map_err(|err| {
+ AutomationRunMaterializeError::Manifest(format!(
+ "manifest build task failed: {err}"
+ ))
+ })?
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct GithubRepository {
+ owner: String,
+ name: String,
+}
+
+fn parse_github_repository_slug(
+ value: &str,
+) -> Result {
+ let Some((owner, repo)) = value.split_once('/') else {
+ return Err(AutomationRunMaterializeError::InvalidTarget(format!(
+ "repository must be a GitHub owner/repo slug: {value}"
+ )));
+ };
+ if repo.contains('/') || !valid_github_owner(owner) || !valid_github_repo(repo) {
+ return Err(AutomationRunMaterializeError::InvalidTarget(format!(
+ "repository must be a GitHub owner/repo slug: {value}"
+ )));
+ }
+ Ok(GithubRepository {
+ owner: owner.to_string(),
+ name: repo.to_string(),
+ })
+}
+
+fn valid_github_owner(value: &str) -> bool {
+ if value.is_empty() || value.len() > 39 {
+ return false;
+ }
+ let bytes = value.as_bytes();
+ let first = bytes[0];
+ let last = bytes[bytes.len() - 1];
+ (first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric())
+ && bytes
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
+}
+
+fn valid_github_repo(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 100
+ && value != "."
+ && value != ".."
+ && !value.starts_with('.')
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
+}
+
+fn github_clone_url(repo: &GithubRepository) -> String {
+ format!("https://github.com/{}/{}.git", repo.owner, repo.name)
+}
+
+fn github_metadata_url(repo: &GithubRepository) -> String {
+ format!("https://github.com/{}/{}", repo.owner, repo.name)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct GitAuthConfig {
+ extraheader: Option,
+ sensitive_values: Vec,
+}
+
+impl GitAuthConfig {
+ fn new(username: Option, password: Option) -> Self {
+ let Some(password) = password.filter(|value| !value.is_empty()) else {
+ return Self {
+ extraheader: None,
+ sensitive_values: Vec::new(),
+ };
+ };
+ let username = username
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "x-access-token".to_string());
+ let encoded_credentials = BASE64_STANDARD.encode(format!("{username}:{password}"));
+ let extraheader = basic_auth_header_from_encoded(&encoded_credentials);
+ Self {
+ sensitive_values: vec![password, encoded_credentials, extraheader.clone()],
+ extraheader: Some(extraheader),
+ }
+ }
+
+ fn git_env(&self, clone_url: &str) -> Vec<(String, String)> {
+ let Some(extraheader) = self.extraheader.as_ref() else {
+ return Vec::new();
+ };
+ vec![
+ ("GIT_CONFIG_COUNT".to_string(), "1".to_string()),
+ (
+ "GIT_CONFIG_KEY_0".to_string(),
+ format!("http.{clone_url}.extraheader"),
+ ),
+ ("GIT_CONFIG_VALUE_0".to_string(), extraheader.clone()),
+ ]
+ }
+
+ fn sensitive_values(&self) -> &[String] {
+ &self.sensitive_values
+ }
+}
+
+async fn resolve_git_auth_config(
+ credentials: Option<&fabro_github::GitHubCredentials>,
+ repo: &GithubRepository,
+ github_api_base_url: &str,
+ http_client: Option,
+) -> anyhow::Result