fix: allow remote ticket targets without server checkout

This commit is contained in:
2026-09-24 05:04:19 +09:00
parent 70eb1120a2
commit e6608dd42f
3 changed files with 55 additions and 39 deletions
+46 -37
View File
@@ -3707,14 +3707,6 @@ fn validated_ticket_implementation_targets(
repository.repository_key repository.repository_key
))) )))
})?; })?;
api.live_repository_reader()?
.observe_merge_target(&repository_id, Some(&ref_selector))
.map_err(|error| {
ApiError::from(Error::InvalidInput(format!(
"Ticket `{ticket_id}` target Repository `{}` selector `{ref_selector}` is not resolvable: {error:?}",
repository.repository_key
)))
})?;
let capabilities = match target.access { let capabilities = match target.access {
TicketTargetAccess::ReadOnly => { TicketTargetAccess::ReadOnly => {
workdir::WorkdirSessionCapabilities::READ_ONLY workdir::WorkdirSessionCapabilities::READ_ONLY
@@ -10932,15 +10924,6 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
.ok_or_else(|| { .ok_or_else(|| {
ticket::TicketError::MissingTargetSelector(repository.repository_key.clone()) ticket::TicketError::MissingTargetSelector(repository.repository_key.clone())
})?; })?;
self.api
.live_repository_reader()
.map_err(|error| ticket::TicketError::Conflict(error.to_string()))?
.observe_merge_target(&repository.repository_id, Some(selector))
.map_err(|error| ticket::TicketError::InvalidTargetSelector {
repository_key: repository.repository_key.clone(),
selector: selector.to_owned(),
reason: format!("{error:?}"),
})?;
Ok(ticket::ResolvedTicketTarget { Ok(ticket::ResolvedTicketTarget {
repository_key: repository.repository_key, repository_key: repository.repository_key,
ref_selector: selector.to_owned(), ref_selector: selector.to_owned(),
@@ -33123,10 +33106,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn mark_ready_resolves_workspace_target_and_closes_lifecycle_bypasses() { async fn mark_ready_accepts_registered_remote_target_and_closes_lifecycle_bypasses() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); let api = test_api_with_remote_repository(dir.path()).await;
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let repository_key = "test-repository"; let repository_key = "test-repository";
@@ -33151,6 +33133,15 @@ mod tests {
TicketTargetAccess::ReadWrite, TicketTargetAccess::ReadWrite,
)] )]
); );
let implementation_targets =
validated_ticket_implementation_targets(&api, &ticket_ref.id).unwrap();
assert_eq!(implementation_targets.len(), 1);
assert_eq!(implementation_targets[0].repository_key, repository_key);
assert_eq!(implementation_targets[0].ref_selector, "develop");
assert_eq!(
implementation_targets[0].access,
TicketTargetAccess::ReadWrite
);
assert_eq!( assert_eq!(
backend backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request) .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request)
@@ -34596,14 +34587,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn browser_queue_eligibility_uses_authoritative_dependency_targets() { async fn browser_queue_eligibility_accepts_registered_remote_dependency_targets() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); let api = test_api_with_remote_repository(dir.path()).await;
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let mut dependency_input = ticket::NewTicket::new("Invalid target dependency"); let mut dependency_input = ticket::NewTicket::new("Remote target dependency");
dependency_input.workflow_state = Some(TicketWorkflowState::Ready); dependency_input.workflow_state = Some(TicketWorkflowState::Ready);
set_test_ticket_target(&mut dependency_input, "test-repository", "missing-ref"); set_test_ticket_target(&mut dependency_input, "test-repository", "remote-feature");
let dependency = backend.create(dependency_input).unwrap(); let dependency = backend.create(dependency_input).unwrap();
let mut root_input = ticket::NewTicket::new("Queue root"); let mut root_input = ticket::NewTicket::new("Queue root");
root_input.workflow_state = Some(TicketWorkflowState::Ready); root_input.workflow_state = Some(TicketWorkflowState::Ready);
@@ -34624,25 +34614,23 @@ mod tests {
assign_test_orchestrator(&api, &dependency.id); assign_test_orchestrator(&api, &dependency.id);
let Json(detail) = browser_ticket_detail(&api, &root.id).unwrap(); let Json(detail) = browser_ticket_detail(&api, &root.id).unwrap();
assert!(!detail.action_eligibility.can_queue); assert!(detail.action_eligibility.can_queue);
assert!( assert!(detail.action_eligibility.blockers.is_empty());
detail let Json(outcome) = scoped_queue_ticket_record(
.action_eligibility
.blockers
.iter()
.any(|blocker| blocker.contains("missing-ref"))
);
let result = scoped_queue_ticket_record(
State(api.clone()), State(api.clone()),
AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())), AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())),
HeaderMap::new(), HeaderMap::new(),
) )
.await; .await
assert!(result.is_err()); .unwrap();
assert_eq!(
outcome.queued_tickets,
vec![dependency.id.clone(), root.id.clone()]
);
for ticket_id in [dependency.id, root.id] { for ticket_id in [dependency.id, root.id] {
assert_eq!( assert_eq!(
backend.show(ticket_id.into()).unwrap().meta.workflow_state, backend.show(ticket_id.into()).unwrap().meta.workflow_state,
TicketWorkflowState::Ready TicketWorkflowState::Queued
); );
} }
} }
@@ -35712,6 +35700,27 @@ mod tests {
test_api_with_recording_backend(workspace_root).await.0 test_api_with_recording_backend(workspace_root).await.0
} }
async fn test_api_with_remote_repository(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let mut config = test_server_config(workspace_root);
let source = server_api::RepositorySource {
kind: server_api::RepositorySourceKind::Ssh,
uri: "git@example.invalid:owner/repository.git".to_string(),
};
config.repositories[0].source_fingerprint =
crate::repository_source::repository_source_fingerprint(&source);
config.repositories[0].source = source;
config.repositories[0].path = None;
config.repositories[0].default_selector = Some("develop".to_string());
let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap();
WorkspaceApi::new_with_execution_backend(
config,
Arc::new(store),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap()
}
async fn test_api_with_docs_repository(workspace_root: impl Into<PathBuf>) -> WorkspaceApi { async fn test_api_with_docs_repository(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let workspace_root = workspace_root.into(); let workspace_root = workspace_root.into();
let docs_root = workspace_root.join("docs-repository"); let docs_root = workspace_root.join("docs-repository");
@@ -184,6 +184,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes("updatedTicket.targets"), true); assertEquals(source.includes("updatedTicket.targets"), true);
assertEquals(source.includes("ticket.repository_key"), false); assertEquals(source.includes("ticket.repository_key"), false);
assertEquals(source.includes("ticket.ref_selector"), false); assertEquals(source.includes("ticket.ref_selector"), false);
assertEquals(
source.includes("(repository.diagnostics ?? []).length > 0"),
false,
);
assertEquals(
source.includes("unique registered repository targets"),
true,
);
}); });
Deno.test("Ticket authoring creates the target collection without singular authority", async () => { Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
@@ -114,7 +114,6 @@
const repository = repositoryFor(target.repository_key); const repository = repositoryFor(target.repository_key);
if ( if (
!target.repository_key || repository === null || !target.repository_key || repository === null ||
(repository.diagnostics ?? []).length > 0 ||
!effectiveRefSelector(target) || !effectiveRefSelector(target) ||
repositoryKeys.has(target.repository_key) repositoryKeys.has(target.repository_key)
) return false; ) return false;
@@ -651,7 +650,7 @@
{busy === "ready" ? "Marking ready…" : "Mark ready"} {busy === "ready" ? "Marking ready…" : "Mark ready"}
</button> </button>
{#if !targetCandidateValid} {#if !targetCandidateValid}
<p class="workspace-empty-copy">Add unique healthy repository targets with effective ref selectors and exactly one read-write target before marking ready.</p> <p class="workspace-empty-copy">Add unique registered repository targets with effective ref selectors and exactly one read-write target before marking ready.</p>
{/if} {/if}
{:else if ticket.state === "ready"} {:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}> <button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>