mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Delete the executor-era error and Git helpers in fabro-workflow
The failure classifiers, the handler and publish error builders, the FailureDetail projections, and the LLM error conversions served the deleted executor; Petri classifies failures now. Error keeps the variants the create and read side construct, and the Engine variant replaces the three-stage Stage shape. git_identity goes: the hooks record git.identity through fabro_checkpoint. git.rs keeps the observe, head, non-interactive push, and sync helpers the server and fabro-manifest call, and loses the push half. fabro-llm loses the failure signature hint whose only reader was the deleted classifier. fabro-workflow drops regex, strum, and fabro-checkpoint. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c0fb71a467
commit
efb43b45aa
8 changed files with 70 additions and 2284 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -3049,7 +3049,6 @@ dependencies = [
|
|||
"chrono",
|
||||
"dirs",
|
||||
"fabro-auth",
|
||||
"fabro-checkpoint",
|
||||
"fabro-client",
|
||||
"fabro-config",
|
||||
"fabro-dump",
|
||||
|
|
@ -3085,13 +3084,11 @@ dependencies = [
|
|||
"pebble-agent",
|
||||
"pebble-coding-agent",
|
||||
"rand 0.9.4",
|
||||
"regex",
|
||||
"sandbox-driver",
|
||||
"scopeguard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"strum 0.28.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
//! The one failure-classification rule that is Fabro's own.
|
||||
//!
|
||||
//! Retry, auth, cancellation, and failover questions are answered by the
|
||||
//! lithos `Error` and `ErrorData` themselves. What stays here is the loop and
|
||||
//! restart detector's signature format, which names Fabro's own categories.
|
||||
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use lithos_llm::types::{ErrorData, ErrorKind};
|
||||
|
||||
/// A stable `category|provider|detail` string for loop and restart detection.
|
||||
///
|
||||
/// The category is `api_canceled` for a cancelled call, `api_transient` for a
|
||||
/// failure the provider may be asked to repeat, and `api_deterministic` for
|
||||
/// everything else; the detail is the error kind's stored spelling.
|
||||
#[must_use]
|
||||
pub fn failure_signature_hint(error: &ErrorData) -> String {
|
||||
let provider = error.provider().map_or("unknown", ProviderId::as_str);
|
||||
let category = if error.is_cancelled() {
|
||||
"api_canceled"
|
||||
} else if error.is_retryable() {
|
||||
"api_transient"
|
||||
} else {
|
||||
"api_deterministic"
|
||||
};
|
||||
let kind: ErrorKind = error.kind();
|
||||
format!("{category}|{provider}|{}", kind.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lithos_llm::types::{Error, RetryClassification};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn error(kind: ErrorKind) -> ErrorData {
|
||||
Error::new(kind, "boom")
|
||||
.with_provider(ProviderId::new("openai"))
|
||||
.data()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_name_category_provider_and_kind() {
|
||||
assert_eq!(
|
||||
failure_signature_hint(&error(ErrorKind::InvalidRequest)),
|
||||
"api_deterministic|openai|invalid_request"
|
||||
);
|
||||
assert_eq!(
|
||||
failure_signature_hint(
|
||||
&Error::new(ErrorKind::RateLimit, "boom")
|
||||
.with_provider(ProviderId::new("openai"))
|
||||
.with_retry(RetryClassification::Safe)
|
||||
.data()
|
||||
),
|
||||
"api_transient|openai|rate_limit"
|
||||
);
|
||||
assert_eq!(
|
||||
failure_signature_hint(&error(ErrorKind::Cancelled)),
|
||||
"api_canceled|openai|cancelled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,7 @@
|
|||
//! - model and provider probes ([`probe`]), and the API views of the catalog
|
||||
//! ([`api`]);
|
||||
//! - the `fabro exec` gateway adapter that speaks to a Fabro server
|
||||
//! ([`gateway`]);
|
||||
//! - the failure signature loop detection reads ([`error`]).
|
||||
//! ([`gateway`]).
|
||||
//!
|
||||
//! Local-file inlining, structured output, readable-reasoning normalization,
|
||||
//! and the retry, auth, and failover predicates are lithos-llm's own.
|
||||
|
|
@ -21,7 +20,6 @@
|
|||
pub mod api;
|
||||
pub mod catalog;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod gateway;
|
||||
pub mod probe;
|
||||
pub mod selection;
|
||||
|
|
@ -33,7 +31,6 @@ pub use client::{
|
|||
ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client,
|
||||
build_offline_client, configured_providers,
|
||||
};
|
||||
pub use error::failure_signature_hint;
|
||||
pub use lithos_llm::client::{Client, ClientBuild};
|
||||
pub use lithos_llm::middleware::{CallContext, CancellationToken, RetryPolicy, RetryStage};
|
||||
pub use lithos_llm::resolver::ModelSelectionError as RouteSelectionError;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ fabro-template = { path = "../../foundation/fabro-template" }
|
|||
fabro-tool = { path = "../fabro-tool" }
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
fabro-redact.workspace = true
|
||||
fabro-checkpoint = { path = "../fabro-checkpoint" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-static.workspace = true
|
||||
|
|
@ -42,7 +41,6 @@ fabro-types = { path = "../../foundation/fabro-types" }
|
|||
lithos-llm = { workspace = true, features = ["runtime"] }
|
||||
fabro-http.workspace = true
|
||||
thiserror.workspace = true
|
||||
strum.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
|
@ -56,7 +54,6 @@ async-trait.workspace = true
|
|||
futures.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
dirs = "6"
|
||||
regex.workspace = true
|
||||
scopeguard = "1"
|
||||
md5.workspace = true
|
||||
hex.workspace = true
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,11 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use fabro_checkpoint::author::GitAuthor;
|
||||
use fabro_redact::DisplaySafeUrl;
|
||||
use fabro_types::{DirtyStatus, GitContext, WorkflowSettings};
|
||||
use tokio::task::{JoinError, spawn_blocking};
|
||||
use tokio::time::timeout;
|
||||
use fabro_types::{DirtyStatus, GitContext};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`).
|
||||
pub const RUN_BRANCH_PREFIX: &str = "fabro/run/";
|
||||
|
||||
/// A local checkout could not be inspected without changing it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GitObservationError {
|
||||
|
|
@ -112,16 +106,6 @@ fn sanitized_origin_url(value: &str) -> String {
|
|||
fabro_github::normalize_repo_origin_url(url.as_str())
|
||||
}
|
||||
|
||||
pub fn git_author_from_settings(settings: &WorkflowSettings) -> GitAuthor {
|
||||
settings
|
||||
.run
|
||||
.git
|
||||
.author
|
||||
.clone()
|
||||
.map(|author| GitAuthor::from(&author))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn git_error(msg: impl Into<String>) -> Error {
|
||||
Error::engine(msg.into())
|
||||
}
|
||||
|
|
@ -138,24 +122,12 @@ fn git_cmd(dir: &Path) -> Command {
|
|||
cmd
|
||||
}
|
||||
|
||||
/// Assert the working directory is a clean git repo (no uncommitted changes).
|
||||
pub fn ensure_clean(repo: &Path) -> Result<()> {
|
||||
tracing::debug!(path = %repo.display(), "Checking git cleanliness");
|
||||
let output = git_cmd(repo)
|
||||
/// Whether the working directory is a git repo with no uncommitted changes.
|
||||
fn working_tree_is_clean(repo: &Path) -> bool {
|
||||
git_cmd(repo)
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.map_err(|e| Error::engine_with_source("git status failed", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_error("not a git repository"));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
return Err(git_error("working directory has uncommitted changes"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
.is_ok_and(|output| output.status.success() && output.stdout.trim_ascii().is_empty())
|
||||
}
|
||||
|
||||
/// Return the SHA of HEAD.
|
||||
|
|
@ -172,50 +144,6 @@ pub fn head_sha(repo: &Path) -> Result<String> {
|
|||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Run a `git push` command and check for success.
|
||||
fn run_git_push(cmd: &mut Command) -> Result<()> {
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| Error::engine_with_source("git push failed", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(git_error(format!("git push failed: {stderr}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push a local ref to an explicit remote URL.
|
||||
///
|
||||
/// Uses a URL (not a named remote) so the host repo's remote config is
|
||||
/// untouched. Disables credential helpers so only the inline URL credentials
|
||||
/// are used.
|
||||
pub fn push_ref(repo: &Path, url: &str, refname: &str) -> Result<()> {
|
||||
let redacted_url = if let Some(at_pos) = url.find('@') {
|
||||
format!("https://***@{}", &url[at_pos + 1..])
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
tracing::info!(
|
||||
repo_dir = %repo.display(),
|
||||
url = %redacted_url,
|
||||
refname,
|
||||
"Pushing ref to remote"
|
||||
);
|
||||
run_git_push(git_cmd(repo).args(["-c", "credential.helper=", "push", url, refname]))
|
||||
}
|
||||
|
||||
/// Push a local branch to the named remote using the user's configured
|
||||
/// credentials.
|
||||
pub fn push_branch(repo: &Path, remote: &str, branch: &str) -> Result<()> {
|
||||
tracing::info!(
|
||||
repo_dir = %repo.display(),
|
||||
remote,
|
||||
branch,
|
||||
"Pushing branch to remote"
|
||||
);
|
||||
run_git_push(git_cmd(repo).args(["push", remote, branch]))
|
||||
}
|
||||
|
||||
/// Push a local branch to the named remote without allowing Git to prompt.
|
||||
pub fn push_branch_noninteractive(repo: &Path, remote: &str, branch: &str) -> Result<()> {
|
||||
tracing::info!(
|
||||
|
|
@ -224,11 +152,16 @@ pub fn push_branch_noninteractive(repo: &Path, remote: &str, branch: &str) -> Re
|
|||
branch,
|
||||
"Pushing branch to remote without terminal prompts"
|
||||
);
|
||||
run_git_push(
|
||||
git_cmd(repo)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(["push", remote, branch]),
|
||||
)
|
||||
let output = git_cmd(repo)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(["push", remote, branch])
|
||||
.output()
|
||||
.map_err(|e| Error::engine_with_source("git push failed", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(git_error(format!("git push failed: {stderr}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the exact commit currently advertised for a remote branch without
|
||||
|
|
@ -265,48 +198,6 @@ pub fn remote_branch_sha_noninteractive(
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// Error from [`blocking_push_with_timeout`].
|
||||
pub enum BlockingPushError {
|
||||
/// The git push itself failed.
|
||||
Push(Error),
|
||||
/// The spawned blocking task panicked.
|
||||
Panicked(JoinError),
|
||||
/// The push did not complete within the timeout.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BlockingPushError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Push(e) => write!(f, "{e}"),
|
||||
Self::Panicked(e) => write!(f, "task panicked: {e}"),
|
||||
Self::TimedOut => write!(f, "timed out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a blocking git-push function with a timeout, flattening the
|
||||
/// triple-nested Result.
|
||||
pub async fn blocking_push_with_timeout<F>(
|
||||
timeout_secs: u64,
|
||||
f: F,
|
||||
) -> std::result::Result<(), BlockingPushError>
|
||||
where
|
||||
F: FnOnce() -> Result<()> + Send + 'static,
|
||||
{
|
||||
match timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
spawn_blocking(f),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Ok(()))) => Ok(()),
|
||||
Ok(Ok(Err(e))) => Err(BlockingPushError::Push(e)),
|
||||
Ok(Err(e)) => Err(BlockingPushError::Panicked(e)),
|
||||
Err(_) => Err(BlockingPushError::TimedOut),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the local branch has commits not yet on the remote.
|
||||
/// On any git error (no remote ref, detached HEAD, etc.), returns true
|
||||
/// so the caller falls back to pushing.
|
||||
|
|
@ -354,7 +245,7 @@ impl std::fmt::Display for GitSyncStatus {
|
|||
|
||||
/// Determine the sync status of the repository relative to a remote.
|
||||
pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncStatus {
|
||||
if ensure_clean(repo).is_err() {
|
||||
if !working_tree_is_clean(repo) {
|
||||
return GitSyncStatus::Dirty;
|
||||
}
|
||||
match branch {
|
||||
|
|
@ -479,26 +370,27 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clean_on_clean_repo() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
assert!(ensure_clean(dir.path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clean_fails_with_dirty_file() {
|
||||
fn sync_status_is_dirty_with_uncommitted_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
assert_ne!(
|
||||
sync_status(dir.path(), "origin", None),
|
||||
GitSyncStatus::Dirty
|
||||
);
|
||||
fs::write(dir.path().join("dirty.txt"), "hello").unwrap();
|
||||
let err = ensure_clean(dir.path()).unwrap_err();
|
||||
assert!(err.to_string().contains("uncommitted changes"));
|
||||
assert_eq!(
|
||||
sync_status(dir.path(), "origin", None),
|
||||
GitSyncStatus::Dirty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clean_fails_on_non_repo() {
|
||||
fn sync_status_is_dirty_on_non_repo() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = ensure_clean(dir.path()).unwrap_err();
|
||||
assert!(err.to_string().contains("not a git repository"));
|
||||
assert_eq!(
|
||||
sync_status(dir.path(), "origin", None),
|
||||
GitSyncStatus::Dirty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -511,10 +403,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn push_branch_fails_for_nonexistent_remote() {
|
||||
fn push_branch_noninteractive_fails_for_nonexistent_remote() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let result = push_branch(dir.path(), "nonexistent", "main");
|
||||
let result = push_branch_noninteractive(dir.path(), "nonexistent", "main");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
//! One Git author and committer identity per run.
|
||||
//!
|
||||
//! The run resolves its identity once, after its GitHub credentials are
|
||||
//! selected and before anything can commit, then uses it everywhere: engine
|
||||
//! checkpoints and metadata commits read it through
|
||||
//! [`git_author_from_settings`](crate::git::git_author_from_settings),
|
||||
//! and every workflow command, prepare step, native agent shell tool, and ACP
|
||||
//! agent launch receives it as the four `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
|
||||
//! variables so plain `git commit` inside the sandbox agrees with the engine.
|
||||
//!
|
||||
//! Resolution order: an explicit, complete `run.git.author`; the run's GitHub
|
||||
//! App bot account; the authenticated user of the run's GitHub PAT; the
|
||||
//! generic Fabro identity. A partial `run.git.author` overlays the fields it
|
||||
//! supplies on whichever identity the credentials resolve to. Only the run's
|
||||
//! selected credentials are consulted: a lookup failure for them is a setup
|
||||
//! error, never a silent change of author.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_github::{GitHubCredentials, identity};
|
||||
use fabro_types::settings::run::GitAuthorSettings;
|
||||
use fabro_types::{GitIdentity, GitIdentitySource, WorkflowSettings};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Environment variables Git reads for the author and committer.
|
||||
pub const GIT_IDENTITY_ENV_KEYS: [&str; 4] = [
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
];
|
||||
|
||||
/// Upper bound on one identity lookup against the GitHub API.
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The outcome of resolving a run's identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedGitIdentity {
|
||||
pub identity: GitIdentity,
|
||||
/// Set when the selected credentials were a standalone installation
|
||||
/// token whose App bot account cannot be determined; the identity fell
|
||||
/// back to the generic Fabro identity (plus any explicit fields).
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// The explicit `run.git.author` fields, trimmed; empty values count as unset.
|
||||
fn explicit_fields(settings: &WorkflowSettings) -> (Option<String>, Option<String>) {
|
||||
let author: Option<&GitAuthorSettings> = settings.run.git.author.as_ref();
|
||||
let field = |value: Option<&String>| {
|
||||
value
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
(
|
||||
field(author.and_then(|author| author.name.as_ref())),
|
||||
field(author.and_then(|author| author.email.as_ref())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Overlay explicit fields on a resolved identity. The source stays that of
|
||||
/// the resolved identity unless both fields are explicit.
|
||||
fn overlay(mut identity: GitIdentity, name: Option<String>, email: Option<String>) -> GitIdentity {
|
||||
if name.is_some() && email.is_some() {
|
||||
identity.source = GitIdentitySource::Explicit;
|
||||
}
|
||||
if let Some(name) = name {
|
||||
identity.name = name;
|
||||
}
|
||||
if let Some(email) = email {
|
||||
identity.email = email;
|
||||
}
|
||||
identity
|
||||
}
|
||||
|
||||
/// Resolve the run's Git identity from its settings and selected credentials.
|
||||
///
|
||||
/// `github_token` is the run's managed token source, used only as the bearer
|
||||
/// for the App bot-account lookup (that endpoint rejects App JWTs).
|
||||
pub async fn resolve_git_identity(
|
||||
settings: &WorkflowSettings,
|
||||
credentials: Option<&GitHubCredentials>,
|
||||
github_token: Option<&Arc<InstallationTokenSource>>,
|
||||
) -> Result<ResolvedGitIdentity, Error> {
|
||||
let (name, email) = explicit_fields(settings);
|
||||
if let (Some(name), Some(email)) = (name.clone(), email.clone()) {
|
||||
return Ok(ResolvedGitIdentity {
|
||||
identity: GitIdentity {
|
||||
name,
|
||||
email,
|
||||
source: GitIdentitySource::Explicit,
|
||||
},
|
||||
warning: None,
|
||||
});
|
||||
}
|
||||
|
||||
let (credential_identity, warning) = match credentials {
|
||||
None => (GitIdentity::fabro_default(), None),
|
||||
Some(GitHubCredentials::Installation(_)) => (
|
||||
GitIdentity::fabro_default(),
|
||||
Some(
|
||||
"The run's GitHub credential is a standalone installation token whose App bot \
|
||||
account cannot be determined; commits use the generic Fabro identity."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Some(credentials) => (
|
||||
lookup_credential_identity(credentials, github_token)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
Error::engine_with_anyhow("Failed to resolve the run's Git identity", err)
|
||||
})?,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(ResolvedGitIdentity {
|
||||
identity: overlay(credential_identity, name, email),
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
async fn lookup_credential_identity(
|
||||
credentials: &GitHubCredentials,
|
||||
github_token: Option<&Arc<InstallationTokenSource>>,
|
||||
) -> anyhow::Result<GitIdentity> {
|
||||
let client = fabro_http::http_client()
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("building HTTP client for GitHub identity lookup")?;
|
||||
let base_url = fabro_github::github_api_base_url();
|
||||
let lookup = async {
|
||||
match credentials {
|
||||
GitHubCredentials::App(app) => {
|
||||
let bearer = match github_token {
|
||||
Some(source) => Some(
|
||||
source
|
||||
.resolve()
|
||||
.await
|
||||
.context("resolving the GitHub token for the App bot lookup")?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let account = identity::lookup_app_bot_identity(
|
||||
&client,
|
||||
app,
|
||||
&base_url,
|
||||
bearer.as_ref().map(|token| token.token.expose()),
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>(GitIdentity {
|
||||
email: account.noreply_email(),
|
||||
name: account.login,
|
||||
source: GitIdentitySource::GithubApp,
|
||||
})
|
||||
}
|
||||
GitHubCredentials::Pat(token) => {
|
||||
let account = identity::lookup_token_identity(&client, token, &base_url).await?;
|
||||
Ok(GitIdentity {
|
||||
email: account.noreply_email(),
|
||||
name: account.login,
|
||||
source: GitIdentitySource::GithubPat,
|
||||
})
|
||||
}
|
||||
GitHubCredentials::Installation(_) => {
|
||||
unreachable!("installation tokens never reach the credential lookup")
|
||||
}
|
||||
}
|
||||
};
|
||||
timeout(LOOKUP_TIMEOUT, lookup)
|
||||
.await
|
||||
.context("GitHub identity lookup timed out")?
|
||||
}
|
||||
|
||||
/// The four Git environment variables for `identity`.
|
||||
#[must_use]
|
||||
pub fn git_identity_env(identity: &GitIdentity) -> [(&'static str, String); 4] {
|
||||
[
|
||||
("GIT_AUTHOR_NAME", identity.name.clone()),
|
||||
("GIT_AUTHOR_EMAIL", identity.email.clone()),
|
||||
("GIT_COMMITTER_NAME", identity.name.clone()),
|
||||
("GIT_COMMITTER_EMAIL", identity.email.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Set the identity variables on `env`, replacing any existing values so the
|
||||
/// run's identity wins over inherited host variables and conflicting run or
|
||||
/// step environment entries.
|
||||
pub fn apply_git_identity_env(env: &mut HashMap<String, String>, identity: &GitIdentity) {
|
||||
for (key, value) in git_identity_env(identity) {
|
||||
env.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::run::GitAuthorSettings;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn settings(name: Option<&str>, email: Option<&str>) -> WorkflowSettings {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
settings.run.git.author = Some(GitAuthorSettings {
|
||||
name: name.map(str::to_string),
|
||||
email: email.map(str::to_string),
|
||||
});
|
||||
settings
|
||||
}
|
||||
|
||||
fn installation() -> GitHubCredentials {
|
||||
GitHubCredentials::Installation(fabro_github::InstallationToken {
|
||||
token: "ghs_token".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_credentials_use_the_generic_identity_without_a_lookup() {
|
||||
let resolved = resolve_git_identity(&WorkflowSettings::default(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity, GitIdentity::fabro_default());
|
||||
assert_eq!(resolved.identity.source, GitIdentitySource::Default);
|
||||
assert!(resolved.warning.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_explicit_author_skips_credential_lookup() {
|
||||
// A PAT lookup would need the network; a complete explicit author
|
||||
// must never get that far.
|
||||
let creds = GitHubCredentials::Pat("ghp_never_used".to_string());
|
||||
let resolved = resolve_git_identity(
|
||||
&settings(Some("Release Bot"), Some("release@example.com")),
|
||||
Some(&creds),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity, GitIdentity {
|
||||
name: "Release Bot".to_string(),
|
||||
email: "release@example.com".to_string(),
|
||||
source: GitIdentitySource::Explicit,
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_explicit_author_overlays_the_resolved_identity() {
|
||||
let resolved = resolve_git_identity(&settings(Some("Only Name"), None), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity, GitIdentity {
|
||||
name: "Only Name".to_string(),
|
||||
email: GitIdentity::DEFAULT_EMAIL.to_string(),
|
||||
source: GitIdentitySource::Default,
|
||||
});
|
||||
|
||||
let resolved = resolve_git_identity(&settings(None, Some("only@example.com")), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity.name, GitIdentity::DEFAULT_NAME);
|
||||
assert_eq!(resolved.identity.email, "only@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blank_explicit_fields_count_as_unset() {
|
||||
let resolved = resolve_git_identity(&settings(Some(" "), Some("")), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity, GitIdentity::fabro_default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_installation_token_falls_back_with_a_warning() {
|
||||
let resolved =
|
||||
resolve_git_identity(&WorkflowSettings::default(), Some(&installation()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity, GitIdentity::fabro_default());
|
||||
let warning = resolved.warning.expect("fallback should warn");
|
||||
assert!(
|
||||
warning.contains("standalone installation token"),
|
||||
"{warning}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_installation_token_keeps_explicit_fields() {
|
||||
let resolved = resolve_git_identity(
|
||||
&settings(None, Some("pinned@example.com")),
|
||||
Some(&installation()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.identity.name, GitIdentity::DEFAULT_NAME);
|
||||
assert_eq!(resolved.identity.email, "pinned@example.com");
|
||||
assert_eq!(resolved.identity.source, GitIdentitySource::Default);
|
||||
assert!(resolved.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_env_replaces_conflicting_entries() {
|
||||
let identity = GitIdentity {
|
||||
name: "fabro-bot[bot]".to_string(),
|
||||
email: "7+fabro-bot[bot]@users.noreply.github.com".to_string(),
|
||||
source: GitIdentitySource::GithubApp,
|
||||
};
|
||||
let mut env = HashMap::from([
|
||||
("GIT_AUTHOR_NAME".to_string(), "someone else".to_string()),
|
||||
(
|
||||
"GIT_COMMITTER_EMAIL".to_string(),
|
||||
"x@example.com".to_string(),
|
||||
),
|
||||
("KEEP".to_string(), "1".to_string()),
|
||||
]);
|
||||
apply_git_identity_env(&mut env, &identity);
|
||||
assert_eq!(env["GIT_AUTHOR_NAME"], "fabro-bot[bot]");
|
||||
assert_eq!(env["GIT_AUTHOR_EMAIL"], identity.email);
|
||||
assert_eq!(env["GIT_COMMITTER_NAME"], "fabro-bot[bot]");
|
||||
assert_eq!(env["GIT_COMMITTER_EMAIL"], identity.email);
|
||||
assert_eq!(env["KEEP"], "1");
|
||||
assert_eq!(env.len(), 5);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@
|
|||
//! what Fabro itself owns: the create-time compile of the Fabro graph the
|
||||
//! read side displays (`pipeline`, `transforms`, `operations`), the run
|
||||
//! records and status vocabulary (`records`, `run_status`), the Git
|
||||
//! helpers a run's platform effects use (`git`, `git_identity`,
|
||||
//! `sandbox_git`), pull request creation (`pull_request`), the run tools an
|
||||
//! agent session calls (`run_tools`, `services`), the built-in web search
|
||||
//! backend (`web_search`).
|
||||
//! helpers a run's platform effects use (`git`, `sandbox_git`), pull
|
||||
//! request creation (`pull_request`), the run tools an agent session calls
|
||||
//! (`run_tools`, `services`), the built-in web search backend
|
||||
//! (`web_search`).
|
||||
|
||||
#![cfg_attr(
|
||||
test,
|
||||
|
|
@ -30,7 +30,6 @@
|
|||
pub mod error;
|
||||
pub mod file_resolver;
|
||||
pub mod git;
|
||||
pub mod git_identity;
|
||||
pub mod operations;
|
||||
pub mod outcome;
|
||||
pub mod pipeline;
|
||||
|
|
@ -39,7 +38,7 @@ pub mod records;
|
|||
pub mod run_lookup;
|
||||
pub mod usage_rollup;
|
||||
|
||||
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_types::ManifestPath;
|
||||
pub use usage_rollup::{
|
||||
ProjectionUsageByModel, ProjectionUsageRollup, ProjectionUsageStage,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue