mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +00:00
Simplify CLI repository selectors and add workflow shorthand
This commit is contained in:
parent
94c7159ef8
commit
86bcc8f128
9 changed files with 391 additions and 157 deletions
|
|
@ -147,15 +147,17 @@ See the [Quick Start](/getting-started/quick-start) to try it out, or browse the
|
|||
registers the workflow and leaves a submitted run for you to start with
|
||||
`fabro start RUN`. `run` also starts it, then attaches unless you pass `--detach`.
|
||||
|
||||
The required positional argument selects the workflow. With no source flags,
|
||||
names are found in the current checkout, then a marked project, then installed
|
||||
user workflows. Explicit local paths retain their usual package roots.
|
||||
The required positional argument selects the workflow. Local names are found in
|
||||
the current checkout, then a marked project, then installed user workflows.
|
||||
Explicit local paths retain their usual package roots.
|
||||
|
||||
```sh
|
||||
fabro run review
|
||||
fabro create ./review.toml --target-path ../app
|
||||
fabro run review --workflow-git acme/workflows --workflow-ref v1.2 \
|
||||
--target-git acme/app --target-branch release
|
||||
fabro create ./review.toml --target-from ../app
|
||||
fabro run acme/workflows@v1.2:review --target acme/app@release
|
||||
# Equivalent explicit flags:
|
||||
fabro run review --workflow-repo acme/workflows --workflow-ref v1.2 \
|
||||
--target-repo acme/app --target-branch release
|
||||
```
|
||||
|
||||
In the last example, workflow instructions come from `acme/workflows`, and the
|
||||
|
|
@ -164,13 +166,21 @@ run works on `acme/app`. Neither selection changes the other. Local workflow pat
|
|||
Without target flags, Fabro keeps its existing cwd/environment-based target
|
||||
inference.
|
||||
|
||||
`--workflow-git OWNER/REPO` acquires source using native Git on your machine. A
|
||||
Remote workflow shorthand is `OWNER/REPO[@REF]:WORKFLOW`. The workflow selector
|
||||
is required: `acme/workflows:review` selects a named workflow, and
|
||||
`acme/workflows@v1.2:./reviews/security.toml` selects a file. Repository default
|
||||
workflows are not supported; without `:WORKFLOW`, the positional argument retains
|
||||
local lookup behavior. Prefix local paths containing a colon with `./`, `../`,
|
||||
or `/` to avoid shorthand parsing. Shorthand cannot be combined with
|
||||
`--workflow-repo` or `--workflow-ref`. Repository slugs currently imply GitHub.com.
|
||||
|
||||
`--workflow-repo OWNER/REPO` acquires source using native Git on your machine. A
|
||||
workflow name selects `.fabro/workflows/NAME/workflow.toml` in that repository;
|
||||
you can also supply an explicit repository-relative `.toml` or `.fabro` file.
|
||||
Absolute paths, traversal, and directory selectors are rejected. A missing remote
|
||||
workflow never falls back to a local or installed workflow.
|
||||
|
||||
`--workflow-ref` requires `--workflow-git`. Omit it, or use `HEAD`, to select the
|
||||
`--workflow-ref` requires `--workflow-repo`. Omit it, or use `HEAD`, to select the
|
||||
remote default branch. You can select a branch, tag, or full 40-hex commit SHA.
|
||||
If a branch and tag share a name, qualify it with `refs/heads/` or `refs/tags/`.
|
||||
Fabro resolves the revision once, fetches that exact commit into a temporary
|
||||
|
|
@ -182,15 +192,19 @@ stops owned Git processes before cleanup; collection already in progress must
|
|||
finish before its files can be removed.
|
||||
|
||||
For local workflows without target flags, the existing `run.scm` repository
|
||||
configuration still participates in target inference. Explicit `--target-path`
|
||||
and `--target-git` selections take precedence over that inferred repository.
|
||||
configuration in `workflow.toml` or `.fabro/project.toml` still participates in
|
||||
target inference, with workflow values overriding project values field by field.
|
||||
For clone-based environments, the configured repository must match the checkout's
|
||||
origin. Without that configuration, omission is equivalent to `--target-from .`.
|
||||
Explicit `--target-from`, `--target-repo`, and `--target` selections take precedence
|
||||
over the configured repository.
|
||||
|
||||
Target selection depends on the environment:
|
||||
|
||||
| Selection | Local environment | Clone-based environment (Docker, Daytona, or plugin) |
|
||||
| --- | --- | --- |
|
||||
| Default cwd or `--target-path PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
|
||||
| `--target-git OWNER/REPO` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
|
||||
| Default cwd or `--target-from PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
|
||||
| `--target-repo OWNER/REPO` or `--target OWNER/REPO[@BRANCH]` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
|
||||
|
||||
For clone-based execution, a target path selects a repository, not a subdirectory
|
||||
working-directory override. Local target files are not uploaded. Existing target
|
||||
|
|
@ -200,10 +214,14 @@ commits fail. Folder targets require the directory to be accessible to the
|
|||
server and its Local execution environment; passing a caller-local path does not
|
||||
transfer it to a remote server.
|
||||
|
||||
`--target-branch` requires `--target-git` and accepts a working branch name, not a
|
||||
`--target-branch` requires `--target-repo` and accepts a working branch name, not a
|
||||
tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI
|
||||
only looks up target metadata; the execution sandbox clones the target.
|
||||
`--target-path` and `--target-git` conflict.
|
||||
The shorthand `--target acme/app@release/v2` selects the working branch
|
||||
`release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes
|
||||
accept working branches, while workflow suffixes accept branches, tags, or SHAs.
|
||||
`--target`, `--target-from`, and `--target-repo` are mutually exclusive.
|
||||
`--target-branch` cannot be combined with `--target`.
|
||||
|
||||
Local Git credential helpers, SSH-agent access through configured URL rewrites,
|
||||
and user network configuration govern source acquisition and remote target
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ fabro create [OPTIONS] <WORKFLOW>
|
|||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
|
||||
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
|
||||
|
||||
#### Options
|
||||
|
||||
|
|
@ -361,12 +361,13 @@ fabro create [OPTIONS] <WORKFLOW>
|
|||
| `--provider <provider>` | Override default LLM provider |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
|
||||
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
|
||||
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
|
||||
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
|
||||
### `fabro deny`
|
||||
|
||||
|
|
@ -1076,7 +1077,7 @@ fabro run [OPTIONS] <WORKFLOW>
|
|||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
|
||||
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
|
||||
|
||||
#### Options
|
||||
|
||||
|
|
@ -1095,12 +1096,13 @@ fabro run [OPTIONS] <WORKFLOW>
|
|||
| `--provider <provider>` | Override default LLM provider |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
|
||||
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
|
||||
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
|
||||
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
|
||||
### `fabro sandbox`
|
||||
|
||||
|
|
|
|||
|
|
@ -233,33 +233,37 @@ pub(crate) struct RunArgs {
|
|||
#[command(flatten)]
|
||||
pub(crate) inputs: InputOverrideArgs,
|
||||
|
||||
/// Workflow name or path (repository-relative with --workflow-git)
|
||||
/// Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
#[arg(required = true)]
|
||||
pub(crate) workflow: Option<PathBuf>,
|
||||
|
||||
/// Acquire workflow source locally from a GitHub OWNER/REPO using native
|
||||
/// Git credentials
|
||||
#[arg(long, value_name = "OWNER/REPO")]
|
||||
pub(crate) workflow_git: Option<GitHubRepositorySlug>,
|
||||
pub(crate) workflow_repo: Option<GitHubRepositorySlug>,
|
||||
|
||||
/// Workflow branch, tag, HEAD (default), or full commit SHA; qualify
|
||||
/// ambiguous names
|
||||
#[arg(long, requires = "workflow_git", value_name = "REF")]
|
||||
#[arg(long, requires = "workflow_repo", value_name = "REF")]
|
||||
pub(crate) workflow_ref: Option<String>,
|
||||
|
||||
/// Observe this target directory instead of cwd; Folder targets require
|
||||
/// server filesystem access
|
||||
#[arg(long, conflicts_with = "target_git", value_name = "PATH")]
|
||||
pub(crate) target_path: Option<PathBuf>,
|
||||
#[arg(long, conflicts_with_all = ["target_repo", "target_repo_selector"], value_name = "PATH")]
|
||||
pub(crate) target_from: Option<PathBuf>,
|
||||
|
||||
/// Target GitHub repository and optional working branch
|
||||
#[arg(long = "target", conflicts_with_all = ["target_repo", "target_branch"], value_name = "OWNER/REPO[@BRANCH]")]
|
||||
pub(crate) target_repo_selector: Option<String>,
|
||||
|
||||
/// Target GitHub OWNER/REPO; the execution sandbox still needs its own
|
||||
/// clone credentials
|
||||
#[arg(long, value_name = "OWNER/REPO")]
|
||||
pub(crate) target_git: Option<GitHubRepositorySlug>,
|
||||
pub(crate) target_repo: Option<GitHubRepositorySlug>,
|
||||
|
||||
/// Target working branch (default: remote default branch), pinned to its
|
||||
/// observed commit
|
||||
#[arg(long, requires = "target_git", value_name = "BRANCH")]
|
||||
#[arg(long, requires = "target_repo", value_name = "BRANCH")]
|
||||
pub(crate) target_branch: Option<String>,
|
||||
|
||||
/// Simulate execution; workflow source may still be fetched and uploaded
|
||||
|
|
@ -2011,16 +2015,16 @@ mod run_selection_grammar_tests {
|
|||
for flags in [
|
||||
vec![
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"refs/tags/v1",
|
||||
"--target-git",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"release/topic",
|
||||
],
|
||||
vec!["./review.toml", "--target-path", "../app"],
|
||||
vec!["./review.toml", "--target-from", "../app"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_ok());
|
||||
}
|
||||
|
|
@ -2031,8 +2035,8 @@ mod run_selection_grammar_tests {
|
|||
for flags in [
|
||||
vec!["review", "--workflow-ref", "v1"],
|
||||
vec!["review", "--target-branch", "release"],
|
||||
vec!["review", "--target-path", ".", "--target-git", "acme/app"],
|
||||
vec!["--workflow-git", "acme/workflows"],
|
||||
vec!["review", "--target-from", ".", "--target-repo", "acme/app"],
|
||||
vec!["--workflow-repo", "acme/workflows"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ pub(crate) async fn create_run(
|
|||
// path. Explicit targets select their own repository independently.
|
||||
let configured_repo_origin_url = match &package {
|
||||
ResolvedWorkflow::Local(package)
|
||||
if args.target_path.is_none() && args.target_git.is_none() =>
|
||||
if args.target_from.is_none()
|
||||
&& args.target_repo.is_none()
|
||||
&& args.target_repo_selector.is_none() =>
|
||||
{
|
||||
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,26 +165,27 @@ mod tests {
|
|||
|
||||
fn run_args() -> RunArgs {
|
||||
RunArgs {
|
||||
target: ServerTargetArgs::default(),
|
||||
inputs: InputOverrideArgs::default(),
|
||||
workflow: Some(PathBuf::from("workflow.fabro")),
|
||||
workflow_git: None,
|
||||
workflow_ref: None,
|
||||
target_path: None,
|
||||
target_git: None,
|
||||
target_branch: None,
|
||||
dry_run: false,
|
||||
auto_approve: false,
|
||||
goal: None,
|
||||
goal_file: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
verbose: false,
|
||||
environment: None,
|
||||
label: Vec::new(),
|
||||
parent: None,
|
||||
preserve_sandbox: false,
|
||||
detach: false,
|
||||
target: ServerTargetArgs::default(),
|
||||
inputs: InputOverrideArgs::default(),
|
||||
workflow: Some(PathBuf::from("workflow.fabro")),
|
||||
workflow_repo: None,
|
||||
workflow_ref: None,
|
||||
target_from: None,
|
||||
target_repo_selector: None,
|
||||
target_repo: None,
|
||||
target_branch: None,
|
||||
dry_run: false,
|
||||
auto_approve: false,
|
||||
goal: None,
|
||||
goal_file: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
verbose: false,
|
||||
environment: None,
|
||||
label: Vec::new(),
|
||||
parent: None,
|
||||
preserve_sandbox: false,
|
||||
detach: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -461,7 +461,15 @@ impl Interruption {
|
|||
}
|
||||
|
||||
pub(crate) fn for_run_args(args: &RunArgs) -> Self {
|
||||
Self::new(args.workflow_git.is_some() || args.target_git.is_some())
|
||||
Self::new(
|
||||
args.workflow_repo.is_some()
|
||||
|| args.target_repo.is_some()
|
||||
|| args.target_repo_selector.is_some()
|
||||
|| args
|
||||
.workflow
|
||||
.as_deref()
|
||||
.is_some_and(|path| super::selection::workflow_shorthand(path).is_some()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `work` to completion, or until Ctrl-C cancels it and every owned
|
||||
|
|
@ -522,6 +530,19 @@ mod tests {
|
|||
use super::super::test_support::{commit_all, write_workflow};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shorthand_acquisition_owns_interruption_but_local_paths_do_not() {
|
||||
for (flags, listens) in [
|
||||
(vec!["acme/workflows:review"], true),
|
||||
(vec!["review", "--target", "acme/app@main"], true),
|
||||
(vec!["./acme/workflows:review"], false),
|
||||
(vec!["review", "--target-from", "."], false),
|
||||
] {
|
||||
let args = super::super::test_support::parse_run_args(flags).unwrap();
|
||||
assert_eq!(Interruption::for_run_args(&args).listens, listens);
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
root: tempfile::TempDir,
|
||||
repo: git2::Repository,
|
||||
|
|
|
|||
|
|
@ -88,12 +88,50 @@ pub(super) fn validate_remote_selector(path: &Path) -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Explicit local paths escape the shorthand grammar, including colons in
|
||||
/// file names. A repository without `:WORKFLOW` retains local lookup behavior.
|
||||
pub(super) fn workflow_shorthand(path: &Path) -> Option<(&str, &str)> {
|
||||
let value = path.to_str()?;
|
||||
if path.is_absolute() || value.starts_with("./") || value.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
value.split_once(':')
|
||||
}
|
||||
|
||||
fn repository_revision(value: &str) -> anyhow::Result<(GitHubRepositorySlug, Option<&str>)> {
|
||||
let (repository, revision) = value
|
||||
.split_once('@')
|
||||
.map_or((value, None), |(repository, revision)| {
|
||||
(repository, Some(revision))
|
||||
});
|
||||
let repository = repository
|
||||
.parse()
|
||||
.context("repository must be a GitHub OWNER/REPO")?;
|
||||
if revision == Some("") {
|
||||
bail!("a revision or branch is required after '@'");
|
||||
}
|
||||
Ok((repository, revision))
|
||||
}
|
||||
|
||||
pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, TargetSelection)> {
|
||||
// Flag co-occurrence rules (`requires`/`conflicts_with`) are enforced by clap.
|
||||
let workflow = args.workflow.as_ref().context("workflow is required")?;
|
||||
let workflow = match &args.workflow_git {
|
||||
None => WorkflowSelection::Local(workflow.clone()),
|
||||
Some(repository) => {
|
||||
let workflow = match (&args.workflow_repo, workflow_shorthand(workflow)) {
|
||||
(_, Some(_)) if args.workflow_repo.is_some() || args.workflow_ref.is_some() => {
|
||||
bail!("workflow shorthand cannot be combined with --workflow-repo or --workflow-ref");
|
||||
}
|
||||
(None, Some((source, selector))) => {
|
||||
let (repository, revision) = repository_revision(source)?;
|
||||
let selector = PathBuf::from(selector);
|
||||
validate_remote_selector(&selector)?;
|
||||
WorkflowSelection::Git {
|
||||
repository,
|
||||
selector,
|
||||
revision: RemoteWorkflowRevision::parse(revision)?,
|
||||
}
|
||||
}
|
||||
(None, None) => WorkflowSelection::Local(workflow.clone()),
|
||||
(Some(repository), _) => {
|
||||
validate_remote_selector(workflow)?;
|
||||
WorkflowSelection::Git {
|
||||
repository: repository.clone(),
|
||||
|
|
@ -102,28 +140,32 @@ pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, Target
|
|||
}
|
||||
}
|
||||
};
|
||||
let target = match (&args.target_path, &args.target_git) {
|
||||
(Some(path), _) => TargetSelection::Path(path.clone()),
|
||||
(_, Some(repository)) => {
|
||||
if args
|
||||
.target_branch
|
||||
.as_deref()
|
||||
.is_some_and(|branch| !repository::is_valid_git_branch_name(branch))
|
||||
{
|
||||
bail!(
|
||||
"target branch must be a working branch name, not a tag, SHA, or qualified ref"
|
||||
);
|
||||
}
|
||||
TargetSelection::Git {
|
||||
repository: repository.clone(),
|
||||
branch: args.target_branch.clone(),
|
||||
}
|
||||
let target = if let Some(value) = &args.target_repo_selector {
|
||||
let (repository, branch) = repository_revision(value)?;
|
||||
git_target(repository, branch)?
|
||||
} else {
|
||||
match (&args.target_from, &args.target_repo) {
|
||||
(Some(path), _) => TargetSelection::Path(path.clone()),
|
||||
(_, Some(repository)) => git_target(repository.clone(), args.target_branch.as_deref())?,
|
||||
_ => TargetSelection::Path(PathBuf::from(".")),
|
||||
}
|
||||
_ => TargetSelection::Path(PathBuf::from(".")),
|
||||
};
|
||||
Ok((workflow, target))
|
||||
}
|
||||
|
||||
fn git_target(
|
||||
repository: GitHubRepositorySlug,
|
||||
branch: Option<&str>,
|
||||
) -> anyhow::Result<TargetSelection> {
|
||||
if branch.is_some_and(|branch| !repository::is_valid_git_branch_name(branch)) {
|
||||
bail!("target branch must be a working branch name, not a tag, SHA, or qualified ref");
|
||||
}
|
||||
Ok(TargetSelection::Git {
|
||||
repository,
|
||||
branch: branch.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -202,6 +244,118 @@ mod adapter_tests {
|
|||
use super::*;
|
||||
use crate::args::{Cli, Commands, RunCommands};
|
||||
|
||||
#[test]
|
||||
fn shorthand_matches_explicit_selections_for_both_commands() {
|
||||
for command in ["run", "create"] {
|
||||
for (suffix, reference) in [
|
||||
("", None),
|
||||
("@v1.2", Some("v1.2")),
|
||||
("@release/v2", Some("release/v2")),
|
||||
("@refs/tags/v1", Some("refs/tags/v1")),
|
||||
(
|
||||
"@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
|
||||
Some("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"),
|
||||
),
|
||||
] {
|
||||
for selector in ["review", "./reviews/security.toml"] {
|
||||
for branch in [None, Some("release/v2")] {
|
||||
let workflow = format!("acme/workflows{suffix}:{selector}");
|
||||
let target = branch.map_or_else(
|
||||
|| "acme/app".to_owned(),
|
||||
|branch| format!("acme/app@{branch}"),
|
||||
);
|
||||
let short = ["fabro", command, &workflow, "--target", &target];
|
||||
let mut explicit = vec![
|
||||
"fabro",
|
||||
command,
|
||||
selector,
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
];
|
||||
if let Some(reference) = reference {
|
||||
explicit.extend(["--workflow-ref", reference]);
|
||||
}
|
||||
if let Some(branch) = branch {
|
||||
explicit.extend(["--target-branch", branch]);
|
||||
}
|
||||
let selections = |argv: &[&str]| {
|
||||
let cli = Cli::try_parse_from(argv).unwrap();
|
||||
let Commands::RunCmd(
|
||||
RunCommands::Run(args) | RunCommands::Create(args),
|
||||
) = *cli.command.unwrap()
|
||||
else {
|
||||
panic!("expected run args")
|
||||
};
|
||||
parse(&args).unwrap()
|
||||
};
|
||||
assert_eq!(selections(&short), selections(&explicit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_preserves_local_paths_and_requires_remote_workflow_selector() {
|
||||
for value in [
|
||||
"review",
|
||||
"dir/review.toml",
|
||||
"acme/workflows",
|
||||
"acme/workflows@v1",
|
||||
"./acme/workflows:review",
|
||||
"../acme/workflows:review",
|
||||
"/tmp/workflows:review",
|
||||
] {
|
||||
let args = parse_run_args([value]).unwrap();
|
||||
assert_eq!(
|
||||
parse(&args).unwrap(),
|
||||
(
|
||||
WorkflowSelection::Local(value.into()),
|
||||
TargetSelection::Path(".".into())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_rejects_malformed_or_conflicting_selections_before_acquisition() {
|
||||
for flags in [
|
||||
vec!["acme/workflows:"],
|
||||
vec!["acme/workflows@:review"],
|
||||
vec!["acme/workflows@HEAD~1:review"],
|
||||
vec!["acme/workflows:../review.toml"],
|
||||
vec!["acme/workflows:/review.toml"],
|
||||
vec!["acme/workflows/extra:review"],
|
||||
vec!["https://github.com/acme/workflows:review"],
|
||||
vec!["acme/workflows:review", "--workflow-repo", "acme/other"],
|
||||
vec!["acme/workflows:review", "--workflow-ref", "v1"],
|
||||
vec!["review", "--target", "acme/app@"],
|
||||
vec!["review", "--target", "acme/app@refs/tags/v1"],
|
||||
vec![
|
||||
"review",
|
||||
"--target",
|
||||
"acme/app@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
|
||||
],
|
||||
vec!["review", "--target", "acme/app@main..next"],
|
||||
vec!["review", "--target", "acme/app/extra"],
|
||||
vec!["review", "--target", "acme/app", "--target-from", "."],
|
||||
vec![
|
||||
"review",
|
||||
"--target",
|
||||
"acme/app",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
],
|
||||
vec!["review", "--target", "acme/app", "--target-branch", "main"],
|
||||
] {
|
||||
if let Ok(args) = parse_run_args(flags.iter().copied()) {
|
||||
assert!(parse(&args).is_err(), "{flags:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_selection_both_commands_share_the_adapter() {
|
||||
for command in ["run", "create"] {
|
||||
|
|
@ -209,11 +363,11 @@ mod adapter_tests {
|
|||
"fabro",
|
||||
command,
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"v1",
|
||||
"--target-git",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"release",
|
||||
|
|
@ -251,33 +405,33 @@ mod adapter_tests {
|
|||
for flags in [
|
||||
[
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"https://github.com/acme/workflows",
|
||||
],
|
||||
["review", "--target-git", "acme/app/extra"],
|
||||
["review", "--target-repo", "acme/app/extra"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_err());
|
||||
}
|
||||
for flags in [
|
||||
vec!["../review.toml", "--workflow-git", "acme/workflows"],
|
||||
vec!["/tmp/review.toml", "--workflow-git", "acme/workflows"],
|
||||
vec!["../review.toml", "--workflow-repo", "acme/workflows"],
|
||||
vec!["/tmp/review.toml", "--workflow-repo", "acme/workflows"],
|
||||
vec![
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"HEAD~1",
|
||||
],
|
||||
vec![
|
||||
"review",
|
||||
"--target-git",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"refs/tags/v1",
|
||||
],
|
||||
vec![
|
||||
"review",
|
||||
"--target-git",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"1234567890123456789012345678901234567890",
|
||||
|
|
|
|||
|
|
@ -74,33 +74,34 @@ fn help() {
|
|||
Usage: fabro create [OPTIONS] <WORKFLOW>
|
||||
|
||||
Arguments:
|
||||
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
|
||||
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
|
||||
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
|
||||
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
|
||||
--dry-run Simulate execution; workflow source may still be fetched and uploaded
|
||||
--auto-approve Auto-approve all human gates
|
||||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||||
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
|
||||
--model <MODEL> Override default LLM model
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
|
||||
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
|
||||
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
|
||||
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
|
||||
--dry-run Simulate execution; workflow source may still be fetched and uploaded
|
||||
--auto-approve Auto-approve all human gates
|
||||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||||
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
|
||||
--model <MODEL> Override default LLM model
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
|
@ -729,7 +730,7 @@ fn create_preserves_configured_repository_inference_but_explicit_target_path_win
|
|||
"--server",
|
||||
&server_url,
|
||||
workflow.to_str().unwrap(),
|
||||
"--target-path",
|
||||
"--target-from",
|
||||
".",
|
||||
])
|
||||
.output()
|
||||
|
|
@ -749,9 +750,36 @@ fn create_preserves_configured_repository_inference_but_explicit_target_path_win
|
|||
.to_string(),
|
||||
sha
|
||||
);
|
||||
environment.assert_calls(2);
|
||||
versions.assert_calls(1);
|
||||
create.assert_calls(1);
|
||||
let config = root.path().join("gitconfig");
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/actual\n",
|
||||
origin.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let shorthand = context
|
||||
.create_cmd()
|
||||
.current_dir(&checkout)
|
||||
.env("GIT_CONFIG_GLOBAL", &config)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.args([
|
||||
"--server",
|
||||
&server_url,
|
||||
workflow.to_str().unwrap(),
|
||||
"--target",
|
||||
"acme/actual@topic",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(shorthand.status.success(), "{}", output_stderr(&shorthand));
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests[0]["target"], requests[1]["target"]);
|
||||
environment.assert_calls(3);
|
||||
versions.assert_calls(2);
|
||||
create.assert_calls(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1744,8 +1772,8 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
|
|||
.closure()
|
||||
.root_id();
|
||||
assert_ne!(local_id, remote_id);
|
||||
for source_kind in ["name", "file", "git"] {
|
||||
for target_kind in ["inferred", "path", "git", "git-plugin"] {
|
||||
for source_kind in ["name", "file", "git", "shorthand"] {
|
||||
for target_kind in ["inferred", "path", "git", "git-plugin", "shorthand"] {
|
||||
let mut command = context.create_cmd();
|
||||
command
|
||||
.current_dir(&caller)
|
||||
|
|
@ -1758,26 +1786,31 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
|
|||
"--goal-file",
|
||||
"goal.txt",
|
||||
]);
|
||||
command.arg(if source_kind == "file" {
|
||||
command.arg(if source_kind == "shorthand" {
|
||||
"acme/workflows@trunk:review"
|
||||
} else if source_kind == "file" {
|
||||
".fabro/workflows/review/workflow.toml"
|
||||
} else {
|
||||
"review"
|
||||
});
|
||||
if source_kind == "git" {
|
||||
command.args([
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"trunk",
|
||||
]);
|
||||
}
|
||||
match target_kind {
|
||||
"shorthand" => {
|
||||
command.args(["--target", "acme/app@release", "--environment", "docker"]);
|
||||
}
|
||||
"path" => {
|
||||
command.args(["--target-path", "../target", "--environment", "local"]);
|
||||
command.args(["--target-from", "../target", "--environment", "local"]);
|
||||
}
|
||||
"git" | "git-plugin" => {
|
||||
command.args([
|
||||
"--target-git",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"release",
|
||||
|
|
@ -1804,7 +1837,7 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
|
|||
assert_eq!(
|
||||
intent["workflow_version_id"],
|
||||
match source_kind {
|
||||
"git" => remote_id,
|
||||
"git" | "shorthand" => remote_id,
|
||||
"file" => file_id,
|
||||
_ => local_id,
|
||||
}
|
||||
|
|
@ -1813,17 +1846,17 @@ fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_inde
|
|||
assert_eq!(intent["goal"], "Caller goal");
|
||||
assert_eq!(intent["target"], match target_kind {
|
||||
"path" => json!({"kind":"folder","path":target.canonicalize().unwrap()}),
|
||||
"git" | "git-plugin" =>
|
||||
"git" | "git-plugin" | "shorthand" =>
|
||||
json!({"kind":"git","repo":"acme/app","branch":"release","sha":target_sha}),
|
||||
_ => json!({"kind":"none"}),
|
||||
});
|
||||
}
|
||||
}
|
||||
local_env.assert_calls(3);
|
||||
docker_env.assert_calls(6);
|
||||
plugin_env.assert_calls(3);
|
||||
versions.assert_calls(12);
|
||||
create.assert_calls(12);
|
||||
local_env.assert_calls(4);
|
||||
docker_env.assert_calls(12);
|
||||
plugin_env.assert_calls(4);
|
||||
versions.assert_calls(20);
|
||||
create.assert_calls(20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1866,7 +1899,7 @@ fn create_leaves_remote_workflow_run_submitted_without_starting() {
|
|||
.env("GIT_TRACE", &trace)
|
||||
.args([
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--server",
|
||||
&format!("{}/api/v1", server.base_url()),
|
||||
|
|
@ -1945,7 +1978,7 @@ fn remote_workflow_explicit_acquisition_failure_has_no_fallback_or_server_mutati
|
|||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.args([
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
reference,
|
||||
|
|
|
|||
|
|
@ -124,33 +124,34 @@ fn help() {
|
|||
Usage: fabro run [OPTIONS] <WORKFLOW>
|
||||
|
||||
Arguments:
|
||||
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
|
||||
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
|
||||
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
|
||||
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
|
||||
--dry-run Simulate execution; workflow source may still be fetched and uploaded
|
||||
--auto-approve Auto-approve all human gates
|
||||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||||
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
|
||||
--model <MODEL> Override default LLM model
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
|
||||
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
|
||||
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
|
||||
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
|
||||
--dry-run Simulate execution; workflow source may still be fetched and uploaded
|
||||
--auto-approve Auto-approve all human gates
|
||||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||||
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
|
||||
--model <MODEL> Override default LLM model
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
|
@ -1225,9 +1226,7 @@ fn run_starts_remote_workflow_once_and_failures_do_not_refetch() {
|
|||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.env("GIT_TRACE", &trace)
|
||||
.args([
|
||||
"review",
|
||||
"--workflow-git",
|
||||
"acme/workflows",
|
||||
"acme/workflows:review",
|
||||
"--server",
|
||||
&format!("{}/api/v1", server.base_url()),
|
||||
"--dry-run",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue