diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 8b324fe37..84831c9a6 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -8,7 +8,12 @@ import type { WorkflowSettings, } from "@qltysh/fabro-api-client"; -import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; +import { + findApiTrigger, + findScheduleTrigger, + gitTarget, + type GitRunTarget, +} from "../lib/automation"; import { Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; @@ -51,7 +56,7 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ export function automationToFormValues(automation: Automation): AutomationFormValues { const apiTrigger = findApiTrigger(automation); const scheduleTrigger = findScheduleTrigger(automation); - const target = automation.target.kind === "git" ? automation.target : null; + const target = gitTarget(automation.target); return { id: automation.id, name: automation.name, @@ -84,9 +89,7 @@ export function automationFormValuesFromRun( run.workflow.graph_name, name, ); - const canonicalTarget = runState?.spec.target?.kind === "git" - ? runState.spec.target - : null; + const canonicalTarget = gitTarget(runState?.spec.target); const repository = canonicalTarget?.repo ?? githubRepositoryFromSettings(settings) ?? githubRepositoryName(run.repository?.name) @@ -129,11 +132,30 @@ export function isFormValid(values: AutomationFormValues): boolean { values.name.trim() !== "" && values.repository.trim() !== "" && values.branch.trim() !== "" && - (values.sha.trim() === "" || /^[0-9a-fA-F]{40}$/.test(values.sha.trim())) && + isOptionalShaValid(values.sha) && values.workflow.trim() !== "" ); } +const GIT_SHA_RE = /^[0-9a-fA-F]{40}$/; + +/** An empty SHA means "no pin"; anything else must be a full 40-hex commit id. */ +function isOptionalShaValid(sha: string): boolean { + const trimmed = sha.trim(); + return trimmed === "" || GIT_SHA_RE.test(trimmed); +} + +/** Canonical Git target sent in create/replace requests. */ +export function targetFromFormValues(values: AutomationFormValues): GitRunTarget { + return { + kind: "git", + repo: values.repository.trim(), + branch: values.branch.trim(), + tag: values.tag.trim() || undefined, + sha: values.sha.trim().toLowerCase() || undefined, + }; +} + function kebabify(value: string): string { return value .toLowerCase() @@ -212,8 +234,7 @@ export function AutomationFormFields({ lockIdAndTarget = false, }: AutomationFormFieldsProps) { const slugTouchedRef = useRef(values.id.length > 0); - const sha = values.sha.trim(); - const shaValid = sha === "" || /^[0-9a-fA-F]{40}$/.test(sha); + const shaValid = isOptionalShaValid(values.sha); function patch(partial: Partial) { onChange({ ...values, ...partial }); diff --git a/apps/fabro-web/app/lib/automation.ts b/apps/fabro-web/app/lib/automation.ts index 118ab61c2..a14ceecb1 100644 --- a/apps/fabro-web/app/lib/automation.ts +++ b/apps/fabro-web/app/lib/automation.ts @@ -1,4 +1,13 @@ -import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; +import type { Automation, AutomationTrigger, RunTarget } from "@qltysh/fabro-api-client"; + +export type GitRunTarget = Extract; + +/** Label shown in place of a repository when an automation's target is not Git-backed. */ +export const UNSUPPORTED_TARGET_LABEL = "Unsupported target"; + +export function gitTarget(target: RunTarget | null | undefined): GitRunTarget | null { + return target?.kind === "git" ? target : null; +} type TriggerOfType = Extract< AutomationTrigger, diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx index 6f63edfe8..d02ed5ec4 100644 --- a/apps/fabro-web/app/routes/automation-detail.tsx +++ b/apps/fabro-web/app/routes/automation-detail.tsx @@ -18,7 +18,12 @@ import type { import { toRunWithStatus } from "../data/runs"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; -import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; +import { + UNSUPPORTED_TARGET_LABEL, + findApiTrigger, + findScheduleTrigger, + gitTarget, +} from "../lib/automation"; import { useAutomation, useAutomationRuns } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { useDataUpdatedAt } from "../hooks/use-data-updated-at"; @@ -93,7 +98,7 @@ function AutomationHeader({ automation }: { automation: Automation }) { const scheduleTrigger = findScheduleTrigger(automation); const apiTrigger = findApiTrigger(automation); - const target = automation.target.kind === "git" ? automation.target : null; + const target = gitTarget(automation.target); const canRun = apiTrigger?.enabled === true; async function onRun() { @@ -140,7 +145,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
- {target?.repo ?? "Unsupported target"} + {target?.repo ?? UNSUPPORTED_TARGET_LABEL} {target ? ( {" ยท "}{target.branch} diff --git a/apps/fabro-web/app/routes/automations-edit.tsx b/apps/fabro-web/app/routes/automations-edit.tsx index 930091cd1..41d5b90c1 100644 --- a/apps/fabro-web/app/routes/automations-edit.tsx +++ b/apps/fabro-web/app/routes/automations-edit.tsx @@ -11,6 +11,7 @@ import { AutomationFormFields, automationToFormValues, isFormValid, + targetFromFormValues, triggersFromFormValues, type AutomationFormValues, } from "../components/automation-form"; @@ -85,14 +86,8 @@ function EditAutomationForm({ automation }: { automation: Automation }) { automationsApi.replaceAutomation(automation.id, automation.revision, { name: trimmedName, description: values.description.trim() || null, - target: { - kind: "git", - repo: values.repository.trim(), - branch: values.branch.trim(), - tag: values.tag.trim() || undefined, - sha: values.sha.trim().toLowerCase() || undefined, - }, - workflow: values.workflow.trim(), + target: targetFromFormValues(values), + workflow: values.workflow.trim(), triggers: triggersFromFormValues(values), }), ); diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx index 976f26d01..3290a5fb1 100644 --- a/apps/fabro-web/app/routes/automations-new.tsx +++ b/apps/fabro-web/app/routes/automations-new.tsx @@ -11,6 +11,7 @@ import { EMPTY_AUTOMATION_FORM, automationFormValuesFromRun, isFormValid, + targetFromFormValues, triggersFromFormValues, type AutomationFormValues, } from "../components/automation-form"; @@ -111,14 +112,8 @@ function AutomationCreateForm({ id: values.id.trim(), name: trimmedName, description: values.description.trim() || null, - target: { - kind: "git", - repo: values.repository.trim(), - branch: values.branch.trim(), - tag: values.tag.trim() || undefined, - sha: values.sha.trim().toLowerCase() || undefined, - }, - workflow: values.workflow.trim(), + target: targetFromFormValues(values), + workflow: values.workflow.trim(), triggers: triggersFromFormValues(values), }), ); diff --git a/apps/fabro-web/app/routes/automations.tsx b/apps/fabro-web/app/routes/automations.tsx index 7102307e3..f46a776fe 100644 --- a/apps/fabro-web/app/routes/automations.tsx +++ b/apps/fabro-web/app/routes/automations.tsx @@ -18,7 +18,12 @@ import { FilterButton } from "../components/runs-list/filter-button"; import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client"; import { Link, useNavigate } from "react-router"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; -import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation"; +import { + UNSUPPORTED_TARGET_LABEL, + findScheduleTrigger, + gitTarget, + hasEnabledApiTrigger, +} from "../lib/automation"; import { useAutomations } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { ConfirmDialog, PRIMARY_BUTTON_CLASS } from "../components/ui"; @@ -82,13 +87,13 @@ const MENU_ITEM_DANGER_CLASS = function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] { const automations = result?.data ?? []; return automations.map((a) => { - const target = a.target.kind === "git" ? a.target : null; + const target = gitTarget(a.target); return { id: a.id, revision: a.revision, name: a.name, workflow: a.workflow, - repository: target?.repo ?? "Unsupported target", + repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL, schedule: findScheduleTrigger(a)?.expression, apiEnabled: hasEnabledApiTrigger(a), icon: slugIconMap[a.workflow] ?? CodeBracketIcon, diff --git a/lib/apps/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs index 7557b7f54..070205ade 100644 --- a/lib/apps/fabro-server/src/automation_materializer.rs +++ b/lib/apps/fabro-server/src/automation_materializer.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_api::types::RunManifest; -use fabro_automation::{AutomationId, AutomationValidationError}; +use fabro_automation::AutomationId; use fabro_config::{EnvironmentLayer, MergeMap}; use fabro_manifest::ManifestBuildInput; -use fabro_types::{DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, RunId}; +use fabro_types::{GitHubRepositorySlug, GitRunTarget, RunId, RunTarget, TargetValidationError}; use tokio::{fs, task}; use crate::git_checkout::{ @@ -32,11 +32,10 @@ pub(crate) struct AutomationRunMaterialized { #[derive(thiserror::Error, Debug)] pub(crate) enum RunMaterializeError { - #[error("invalid repository target {value:?}")] + #[error("invalid automation Git target")] InvalidTarget { - value: String, #[source] - source: AutomationValidationError, + source: TargetValidationError, }, #[error("failed to prepare automation checkout")] Checkout { @@ -117,7 +116,11 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer { &self, input: AutomationRunMaterializeInput, ) -> Result { - let repo = parse_target_repository(&input.target.repo)?; + let repo = GitHubRepositorySlug::try_new(&input.target.repo).ok_or( + RunMaterializeError::InvalidTarget { + source: TargetValidationError::Repository, + }, + )?; fs::create_dir_all(&input.temp_root) .await .map_err(|source| RunMaterializeError::TempDirectory { @@ -162,10 +165,7 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer { workflow: input.workflow, user_settings_path: input.user_settings_path, checkout_dir, - git_context: ManifestGitContextInput { - repo, - target: exact_target, - }, + target: exact_target, environment_defaults: self.environment_defaults.clone(), }; task::spawn_blocking(move || build_manifest_from_checkout(manifest_input)) @@ -174,30 +174,15 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer { } } -fn parse_target_repository(value: &str) -> Result { - fabro_automation::parse_github_repository_slug(value).map_err(|source| { - RunMaterializeError::InvalidTarget { - value: value.to_string(), - source, - } - }) -} - #[derive(Debug)] pub(crate) struct ManifestFromCheckoutInput { workflow: String, user_settings_path: PathBuf, checkout_dir: PathBuf, - git_context: ManifestGitContextInput, + target: GitRunTarget, environment_defaults: MergeMap, } -#[derive(Debug)] -pub(crate) struct ManifestGitContextInput { - repo: GitHubRepositorySlug, - target: GitRunTarget, -} - fn build_manifest_from_checkout( args: ManifestFromCheckoutInput, ) -> Result { @@ -205,9 +190,17 @@ fn build_manifest_from_checkout( workflow, user_settings_path, checkout_dir, - git_context, + target, environment_defaults, } = args; + // Re-validating the exact target (now carrying the checked-out SHA) yields + // the same `GitContext` projection the run-intent path uses. + let validated = RunTarget::Git(target) + .validate() + .map_err(|source| RunMaterializeError::InvalidTarget { source })?; + let RunTarget::Git(target) = validated.target else { + unreachable!("validating a Git target yields a Git target"); + }; let built = fabro_manifest::build_run_manifest(ManifestBuildInput { workflow: workflow.into(), cwd: checkout_dir, @@ -218,18 +211,13 @@ fn build_manifest_from_checkout( .map_err(manifest_build_error)?; let mut manifest = built.manifest; - manifest.git = Some(GitContext { - origin_url: git_context.repo.https_url(), - branch: git_context.target.branch.clone(), - sha: git_context.target.sha.clone(), - dirty: DirtyStatus::Clean, - }); + manifest.git = validated.git; let submitted_manifest_bytes = serde_json::to_vec(&manifest) .map_err(|source| RunMaterializeError::SerializeManifest { source })?; Ok(AutomationRunMaterialized { manifest, submitted_manifest_bytes, - target: git_context.target, + target, }) } @@ -254,14 +242,7 @@ pub struct TestAutomationRunMaterializer { #[cfg(any(test, feature = "test-support"))] struct TestAutomationRunMaterializerState { captured_inputs: Vec, - response: TestMaterializeResponse, -} - -#[cfg(any(test, feature = "test-support"))] -#[derive(Clone)] -enum TestMaterializeResponse { - Success(Box), - InvalidTarget(String), + response: Result, TargetValidationError>, } #[cfg(any(test, feature = "test-support"))] @@ -271,20 +252,18 @@ impl TestAutomationRunMaterializer { submitted_manifest_bytes: Vec, target: GitRunTarget, ) -> Self { - Self::new(TestMaterializeResponse::Success(Box::new( - AutomationRunMaterialized { - manifest, - submitted_manifest_bytes, - target, - }, - ))) + Self::new(Ok(Box::new(AutomationRunMaterialized { + manifest, + submitted_manifest_bytes, + target, + }))) } - pub fn fail_invalid_target(message: impl Into) -> Self { - Self::new(TestMaterializeResponse::InvalidTarget(message.into())) + pub fn fail_invalid_target() -> Self { + Self::new(Err(TargetValidationError::Repository)) } - fn new(response: TestMaterializeResponse) -> Self { + fn new(response: Result, TargetValidationError>) -> Self { Self { inner: std::sync::Arc::new(std::sync::Mutex::new(TestAutomationRunMaterializerState { captured_inputs: Vec::new(), @@ -318,17 +297,11 @@ impl AutomationRunMaterializer for TestAutomationRunMaterializer { .lock() .expect("test automation materializer lock poisoned"); guard.captured_inputs.push(input); - match guard.response.clone() { - TestMaterializeResponse::Success(materialized) => Ok(*materialized), - TestMaterializeResponse::InvalidTarget(value) => { - Err(RunMaterializeError::InvalidTarget { - source: AutomationValidationError::InvalidRepositorySlug { - value: value.clone(), - }, - value, - }) - } - } + guard + .response + .clone() + .map(|materialized| *materialized) + .map_err(|source| RunMaterializeError::InvalidTarget { source }) } } @@ -373,21 +346,17 @@ mod tests { .unwrap(); let user_settings_path = temp.path().join("settings.toml"); fs::write(&user_settings_path, "_version = 1\n").unwrap(); - let repo = parse_target_repository("workspace-org/app").unwrap(); let sha = "0123456789abcdef0123456789abcdef01234567".to_string(); let materialized = build_manifest_from_checkout(ManifestFromCheckoutInput { workflow: "demo".to_string(), user_settings_path: user_settings_path.clone(), checkout_dir: checkout.clone(), - git_context: ManifestGitContextInput { - repo, - target: GitRunTarget { - repo: "workspace-org/app".to_string(), - branch: "release".to_string(), - tag: Some("v1".to_string()), - sha: Some(sha.clone()), - }, + target: GitRunTarget { + repo: "workspace-org/app".to_string(), + branch: "release".to_string(), + tag: Some("v1".to_string()), + sha: Some(sha.clone()), }, environment_defaults: test_environment_defaults(), }) diff --git a/lib/apps/fabro-server/src/server/automation_scheduler.rs b/lib/apps/fabro-server/src/server/automation_scheduler.rs index bab505eec..4dafca804 100644 --- a/lib/apps/fabro-server/src/server/automation_scheduler.rs +++ b/lib/apps/fabro-server/src/server/automation_scheduler.rs @@ -714,7 +714,7 @@ mod tests { #[tokio::test] async fn failing_materializer_waits_until_next_cron_occurrence() { - let materializer = TestAutomationRunMaterializer::fail_invalid_target("boom"); + let materializer = TestAutomationRunMaterializer::fail_invalid_target(); let state = test_state_with_materializer(materializer.clone()); create_automation(state.as_ref(), "nightly", "Nightly", vec![ schedule_trigger("schedule", "* * * * *", true), diff --git a/lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs b/lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs index bec6b199e..1d392cd99 100644 --- a/lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs +++ b/lib/components/fabro-automation/migrations/2026071101_file_definitions_to_sqlite.rs @@ -96,7 +96,12 @@ fn parse_legacy_automation( .map_err(|source| AutomationStoreError::invalid_utf8(path, source))?; let legacy: LegacyPersistedAutomation = toml::from_str(content).map_err(|source| AutomationStoreError::parse(path, source))?; - let (target, workflow) = legacy_target(legacy.target, path)?; + let LegacyAutomationTarget { + repository, + selector, + workflow, + } = legacy.target; + let target = legacy_target(repository, &selector, path)?; Automation::from_stored(id.clone(), revision, AutomationReplace { name: legacy.name, description: legacy.description, @@ -108,10 +113,10 @@ fn parse_legacy_automation( } fn legacy_target( - legacy: LegacyAutomationTarget, + repository: String, + selector: &str, path: &Path, -) -> Result<(RunTarget, String), AutomationStoreError> { - let selector = legacy.selector.as_str(); +) -> Result { let (branch, tag, sha) = if let Some(sha) = repository::normalize_git_commit_sha(selector) { ("main".to_string(), None, Some(sha)) } else if let Some(tag) = selector @@ -129,19 +134,18 @@ fn legacy_target( } else { (selector.to_string(), None, None) }; - let target = RunTarget::Git(GitRunTarget { - repo: legacy.repository, + RunTarget::Git(GitRunTarget { + repo: repository, branch, tag, sha, }) .validate() + .map(|validated| validated.target) .map_err(|source| AutomationStoreError::LegacyTarget { path: path.to_path_buf(), source, - })? - .target; - Ok((target, legacy.workflow)) + }) } async fn legacy_automation_paths( diff --git a/lib/components/fabro-automation/src/error.rs b/lib/components/fabro-automation/src/error.rs index 06d1540d0..3b8e37910 100644 --- a/lib/components/fabro-automation/src/error.rs +++ b/lib/components/fabro-automation/src/error.rs @@ -15,8 +15,6 @@ pub enum AutomationValidationError { InvalidAutomationTriggerId { value: String }, #[error("automation name must not be empty")] EmptyName, - #[error("repository slug {value:?} must be a GitHub owner/repo slug")] - InvalidRepositorySlug { value: String }, #[error("automation target kind {kind:?} is not supported; only Git targets are accepted")] UnsupportedTarget { kind: String }, #[error("automation Git target is invalid")] diff --git a/lib/components/fabro-automation/src/lib.rs b/lib/components/fabro-automation/src/lib.rs index 3064413fe..6f2e49388 100644 --- a/lib/components/fabro-automation/src/lib.rs +++ b/lib/components/fabro-automation/src/lib.rs @@ -10,6 +10,6 @@ pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, Aut pub use migrations::{ImportReport, import_legacy_directory_once}; pub use model::{ ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger, - parse_github_repository_slug, parse_schedule_expression, + parse_schedule_expression, }; pub use store::AutomationStore; diff --git a/lib/components/fabro-automation/src/model.rs b/lib/components/fabro-automation/src/model.rs index 73ec667f9..8ce0d314c 100644 --- a/lib/components/fabro-automation/src/model.rs +++ b/lib/components/fabro-automation/src/model.rs @@ -4,7 +4,7 @@ use std::sync::LazyLock; use croner::Cron; use croner::errors::CronError; use croner::parser::{CronParser, Seconds, Year}; -use fabro_types::{GitHubRepositorySlug, GitRunTarget, RunTarget}; +use fabro_types::{GitRunTarget, RunTarget}; use serde::{Deserialize, Serialize}; use crate::{ @@ -330,16 +330,6 @@ fn normalize_replace( Ok(value) } -pub fn parse_github_repository_slug( - value: &str, -) -> Result { - GitHubRepositorySlug::try_new(value).ok_or_else(|| { - AutomationValidationError::InvalidRepositorySlug { - value: value.to_string(), - } - }) -} - fn validate_target(target: RunTarget) -> Result { if !matches!(&target, RunTarget::Git(_)) { return Err(AutomationValidationError::UnsupportedTarget { @@ -532,30 +522,6 @@ enabled = true assert_eq!(trigger_ids, vec!["nightly"]); } - #[test] - fn repository_slug_parser_returns_the_shared_type() { - let slug: fabro_types::GitHubRepositorySlug = - crate::parse_github_repository_slug("owner/.github").unwrap(); - - assert_eq!(slug.owner(), "owner"); - assert_eq!(slug.repo(), ".github"); - } - - #[test] - fn invalid_repository_slug_preserves_the_automation_error() { - let error = crate::parse_github_repository_slug("not/github/slug").unwrap_err(); - - assert!(matches!( - &error, - AutomationValidationError::InvalidRepositorySlug { value } - if value == "not/github/slug" - )); - assert_eq!( - error.to_string(), - "repository slug \"not/github/slug\" must be a GitHub owner/repo slug" - ); - } - #[test] fn invalid_git_target_preserves_the_shared_validation_error() { let error = super::validate_target(RunTarget::Git(GitRunTarget { diff --git a/lib/components/fabro-automation/src/store.rs b/lib/components/fabro-automation/src/store.rs index 60a05f048..9ea250430 100644 --- a/lib/components/fabro-automation/src/store.rs +++ b/lib/components/fabro-automation/src/store.rs @@ -95,7 +95,7 @@ impl AutomationStore { draft: AutomationReplace, ) -> Result { let (automation, _) = Automation::from_replace(id.clone(), draft)?; - let target = git_target(&automation.target); + let target = stored_git_target(&automation); let mut transaction = self.pool.begin().await?; let result = sqlx::query( r" @@ -283,7 +283,7 @@ pub(crate) async fn insert_automation_ignoring_conflict( transaction: &mut Transaction<'_, Sqlite>, automation: &Automation, ) -> Result { - let target = git_target(&automation.target); + let target = stored_git_target(automation); let result = sqlx::query( r" INSERT INTO automations ( @@ -320,13 +320,10 @@ pub(crate) async fn insert_automation_ignoring_conflict( Ok(true) } -fn git_target(target: &RunTarget) -> &GitRunTarget { - match target { - RunTarget::Git(target) => target, - RunTarget::None {} | RunTarget::Folder { .. } => { - unreachable!("stored automations have already passed Git-only validation") - } - } +fn stored_git_target(automation: &Automation) -> &GitRunTarget { + automation + .git_target() + .expect("stored automations have already passed Git-only validation") } async fn insert_schedule_triggers( diff --git a/lib/foundation/fabro-db/migrations/2026082601_automation_run_targets.sql b/lib/foundation/fabro-db/migrations/2026082601_automation_run_targets.sql index b1fa685d4..2f7812d4c 100644 --- a/lib/foundation/fabro-db/migrations/2026082601_automation_run_targets.sql +++ b/lib/foundation/fabro-db/migrations/2026082601_automation_run_targets.sql @@ -94,20 +94,10 @@ ALTER TABLE automations ADD COLUMN target_sha TEXT UPDATE automations SET - target_branch = ( - SELECT branch - FROM automation_target_migration_candidates - WHERE automation_target_migration_candidates.id = automations.id - ), - target_tag = ( - SELECT tag - FROM automation_target_migration_candidates - WHERE automation_target_migration_candidates.id = automations.id - ), - target_sha = ( - SELECT sha - FROM automation_target_migration_candidates - WHERE automation_target_migration_candidates.id = automations.id - ); + target_branch = candidates.branch, + target_tag = candidates.tag, + target_sha = candidates.sha +FROM automation_target_migration_candidates AS candidates +WHERE candidates.id = automations.id; DROP TABLE automation_target_migration_candidates;