use super::*;
use chrono::{DateTime, SecondsFormat, Utc};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
const DEFAULT_LINEAR_ENDPOINT: &str = "https://api.linear.app/graphql";
const DEFAULT_BRANCH_PREFIX: &str = "amon-hen/linear/";
const DEFAULT_WORKFLOW_FILE: &str = "WORKFLOW.md";
const DEFAULT_MAX_ATTEMPTS: usize = 3;
const DEFAULT_POLL_INTERVAL_MS: u64 = 60_000;
const DEFAULT_DELIVERY_PHASES: [&str; 4] = ["plan", "implement", "verify", "ship"];
const TERMINAL_STATUSES: [&str; 5] = ["delivered", "review_ready", "ci_passed", "blocked", "no_op"];
#[derive(Debug, Clone, Serialize)]
pub(super) struct LinearDeliveryResult {
provider: String,
pub(super) success: bool,
pub(super) duration_ms: u128,
issue_count: usize,
phases: Vec<String>,
watch: bool,
poll_count: usize,
until_complete: bool,
completion_gate: String,
state_file: String,
workspace_root: String,
observability_log: String,
polls: Vec<DeliveryPollResult>,
issues: Vec<IssueDeliveryResult>,
}
#[derive(Debug, Clone, Serialize)]
struct DeliveryPollResult {
poll: usize,
fetched: usize,
dispatched: usize,
skipped: usize,
complete: bool,
issues: Vec<IssueDeliveryResult>,
}
#[derive(Debug, Clone, Serialize)]
struct IssueDeliveryResult {
issue: LinearIssue,
success: bool,
status: String,
workspace: IssueWorkspace,
attempts: usize,
next_retry_at: Option<String>,
completion: CompletionResult,
phases: Vec<PhaseDeliveryResult>,
media_attachments: Vec<MediaAttachmentResult>,
comments: Vec<LinearCommentResult>,
state_update: Option<StateUpdateResult>,
}
#[derive(Debug, Clone, Serialize)]
struct PhaseDeliveryResult {
phase: String,
summary_status: String,
summarizer: String,
output: String,
git_before: GitSnapshot,
git_after: GitSnapshot,
gates: Vec<DeliveryGateResult>,
}
#[derive(Debug, Clone, Serialize)]
struct CompletionResult {
success: bool,
status: String,
gate: String,
detail: String,
pr_url: Option<String>,
gates: Vec<DeliveryGateResult>,
repo_changed: bool,
}
#[derive(Debug, Clone, Serialize)]
struct DeliveryGateResult {
name: String,
status: String,
detail: String,
command: Option<String>,
exit_code: Option<i32>,
duration_ms: Option<u128>,
}
#[derive(Debug, Clone, Serialize)]
struct GitSnapshot {
available: bool,
inside_work_tree: bool,
branch: Option<String>,
head: Option<String>,
status: String,
diff_name_status: String,
detail: String,
}
#[derive(Debug, Clone)]
struct WorkspaceSnapshot {
git: GitSnapshot,
fingerprint: Vec<String>,
}
#[derive(Debug, Clone)]
struct DeliveryHarnessResult {
gates: Vec<DeliveryGateResult>,
repo_changed: bool,
blocked_detail: Option<String>,
no_op_detail: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct MediaAttachmentResult {
source: String,
url: Option<String>,
attachment_id: Option<String>,
title: Option<String>,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct LinearCommentResult {
id: Option<String>,
url: Option<String>,
status: String,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct StateUpdateResult {
state: String,
status: String,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DeliveryState {
version: usize,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
issues: HashMap<String, IssueDeliveryState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct IssueDeliveryState {
#[serde(rename = "issueId")]
issue_id: String,
identifier: String,
title: String,
url: Option<String>,
project: Option<LinearEntity>,
epic: Option<LinearEntity>,
status: String,
attempts: usize,
workspace: Option<String>,
#[serde(rename = "workspaceStrategy")]
workspace_strategy: Option<String>,
branch: Option<String>,
phases: Vec<PhaseState>,
#[serde(rename = "lastAttemptAt")]
last_attempt_at: Option<String>,
#[serde(rename = "nextRetryAt")]
next_retry_at: Option<String>,
#[serde(rename = "lastError")]
last_error: Option<String>,
#[serde(rename = "completedAt")]
completed_at: Option<String>,
#[serde(rename = "deliveredAt")]
delivered_at: Option<String>,
#[serde(rename = "reviewReadyAt")]
review_ready_at: Option<String>,
#[serde(rename = "ciPassedAt")]
ci_passed_at: Option<String>,
#[serde(rename = "completionGate")]
completion_gate: Option<String>,
#[serde(rename = "completionDetail")]
completion_detail: Option<String>,
#[serde(rename = "prUrl")]
pr_url: Option<String>,
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PhaseState {
phase: String,
status: String,
summarizer: Option<String>,
#[serde(rename = "completedAt")]
completed_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct LinearStatus {
configured: bool,
auth_method: String,
endpoint: String,
api_key_env: String,
oauth_token_env: String,
viewer: Option<LinearViewer>,
auth_error: Option<String>,
state_file: String,
workspace_root: String,
observability_log: String,
counts: DeliveryCounts,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LinearViewer {
id: Option<String>,
name: Option<String>,
email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
struct DeliveryCounts {
total: usize,
delivered: usize,
review_ready: usize,
ci_passed: usize,
blocked: usize,
no_op: usize,
running: usize,
retry_wait: usize,
failed: usize,
ineligible: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct LinearIssue {
id: String,
identifier: String,
title: String,
description: String,
priority: Option<i64>,
url: Option<String>,
#[serde(rename = "branchName")]
branch_name: Option<String>,
project: Option<LinearEntity>,
epic: Option<LinearEntity>,
state: Option<String>,
team: Option<String>,
assignee: Option<String>,
labels: Vec<String>,
#[serde(rename = "createdAt")]
created_at: Option<String>,
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct LinearEntity {
id: Option<String>,
name: Option<String>,
slug: Option<String>,
identifier: Option<String>,
title: Option<String>,
url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct IssueWorkspace {
cwd: String,
strategy: String,
branch: Option<String>,
}
#[derive(Debug, Clone)]
struct DeliveryPaths {
state_file: PathBuf,
workspace_root: PathBuf,
observability_dir: PathBuf,
events_file: PathBuf,
}
#[derive(Clone)]
struct DeliveryEventContext {
paths: DeliveryPaths,
event_lock: Arc<Mutex<()>>,
progress: Option<ProgressSink>,
cancel: Option<Arc<AtomicBool>>,
}
struct DeliveryRunContext<'a> {
resolved: &'a ResolvedArgs,
auth: &'a str,
phases: &'a [String],
workflow_policy: &'a str,
events: &'a DeliveryEventContext,
completion_gate: &'a str,
}
struct IssueWorkItem {
issue: LinearIssue,
workspace: IssueWorkspace,
issue_state: IssueDeliveryState,
}
pub(super) fn get_linear_status(resolved: &ResolvedArgs) -> Result<LinearStatus, String> {
let paths = resolve_delivery_paths(resolved);
let auth = resolve_linear_authorization(resolved, true)?;
let state = load_delivery_state(&paths.state_file);
let mut viewer = None;
let mut auth_error = None;
if let Some(auth) = auth.as_ref() {
match fetch_linear_viewer(resolved, auth) {
Ok(value) => viewer = value,
Err(error) => auth_error = Some(error),
}
}
Ok(LinearStatus {
configured: auth.is_some(),
auth_method: resolved.raw.linear_auth.clone(),
endpoint: linear_endpoint(resolved),
api_key_env: resolved.raw.linear_api_key_env.clone(),
oauth_token_env: resolved.raw.linear_oauth_token_env.clone(),
viewer,
auth_error,
state_file: paths.state_file.display().to_string(),
workspace_root: paths.workspace_root.display().to_string(),
observability_log: paths.events_file.display().to_string(),
counts: summarize_delivery_state(&state),
})
}
pub(super) fn render_linear_status(status: &LinearStatus) -> String {
let mut lines = vec![
"Linear integration status".to_string(),
format!(
"Auth: {}",
if status.configured {
format!("configured ({})", status.auth_method)
} else {
"missing".to_string()
}
),
format!("Endpoint: {}", status.endpoint),
format!("State file: {}", status.state_file),
format!("Workspace root: {}", status.workspace_root),
format!("Observability log: {}", status.observability_log),
];
if let Some(viewer) = &status.viewer {
lines.push(format!(
"Viewer: {}",
viewer
.name
.as_deref()
.or(viewer.email.as_deref())
.or(viewer.id.as_deref())
.unwrap_or("unknown")
));
} else if let Some(error) = &status.auth_error {
lines.push(format!("Viewer: unavailable ({error})"));
}
lines.extend([
String::new(),
"Setup".to_string(),
format!(
"- API key auth: set {} and use --linear-auth api-key.",
status.api_key_env
),
format!(
"- OAuth auth: set {} and use --linear-auth oauth.",
status.oauth_token_env
),
"- Long-running mode: add --linear-watch with --linear-team/--linear-state filters.".to_string(),
"- Completion loop: add --linear-until-complete with --linear-project/--linear-epic and a completion gate.".to_string(),
"- Isolated workspaces are persisted under the workspace root; state and events are written for retry/reconciliation.".to_string(),
"- Delivery comments are posted back to Linear by default; disable with --no-linear-comments.".to_string(),
"- Review-state transitions are available with --linear-update-review-state plus --linear-review-state.".to_string(),
String::new(),
"Local state".to_string(),
format!("- total: {}", status.counts.total),
format!("- delivered: {}", status.counts.delivered),
format!("- review_ready: {}", status.counts.review_ready),
format!("- ci_passed: {}", status.counts.ci_passed),
format!("- blocked: {}", status.counts.blocked),
format!("- no_op: {}", status.counts.no_op),
format!("- running: {}", status.counts.running),
format!("- retry_wait: {}", status.counts.retry_wait),
format!("- failed: {}", status.counts.failed),
format!("- ineligible: {}", status.counts.ineligible),
]);
lines.join("\n")
}
pub(super) fn run_linear_delivery_with_progress(
resolved: &ResolvedArgs,
progress: Option<ProgressSink>,
cancel: Option<Arc<AtomicBool>>,
) -> Result<LinearDeliveryResult, String> {
let started = Instant::now();
let paths = resolve_delivery_paths(resolved);
let auth = resolve_linear_authorization(resolved, false)?
.ok_or_else(|| "Linear delivery credentials are missing.".to_string())?;
let phases = delivery_phases(resolved);
let mut state = load_delivery_state(&paths.state_file);
let workflow_policy = read_workflow_policy(resolved);
let completion_gate = normalize_completion_gate(&resolved.raw.linear_completion_gate);
let watch = effective_linear_watch(resolved);
let context = DeliveryEventContext {
paths: paths.clone(),
event_lock: Arc::new(Mutex::new(())),
progress,
cancel,
};
let mut poll_results = Vec::new();
let mut poll_count = 0usize;
ensure_delivery_dirs(&paths)?;
emit_event(
&context,
"delivery_started",
json!({
"provider": "linear",
"phases": phases,
"issueIds": resolved.raw.linear_issue,
"projects": resolved.raw.linear_project,
"epics": resolved.raw.linear_epic,
"watch": watch,
"untilComplete": resolved.raw.linear_until_complete,
"completionGate": completion_gate,
"maxPolls": resolved.raw.linear_max_polls,
"maxConcurrency": resolved.raw.linear_max_concurrency,
"stateFile": paths.state_file,
"workspaceRoot": paths.workspace_root,
"observabilityLog": paths.events_file
}),
)?;
loop {
ensure_not_cancelled(&context)?;
poll_count += 1;
let run_context = DeliveryRunContext {
resolved,
auth: &auth,
phases: &phases,
workflow_policy: &workflow_policy,
events: &context,
completion_gate: &completion_gate,
};
let poll = run_linear_delivery_poll(&run_context, &mut state, poll_count)?;
let complete = poll.complete;
poll_results.push(poll);
if resolved.raw.linear_until_complete && complete {
emit_event(
&context,
"delivery_target_completed",
json!({ "poll": poll_count, "completionGate": completion_gate }),
)?;
break;
}
if resolved
.raw
.linear_max_polls
.is_some_and(|max_polls| poll_count >= max_polls)
{
emit_event(
&context,
"delivery_max_polls_reached",
json!({ "poll": poll_count, "maxPolls": resolved.raw.linear_max_polls }),
)?;
break;
}
if !watch {
break;
}
let poll_interval = if resolved.raw.linear_poll_interval == 0 {
DEFAULT_POLL_INTERVAL_MS
} else {
resolved.raw.linear_poll_interval.saturating_mul(1000)
};
sleep_with_cancel(&context, Duration::from_millis(poll_interval.max(1000)))?;
}
let issues = poll_results
.iter()
.flat_map(|poll| poll.issues.iter().cloned())
.collect::<Vec<_>>();
let success = if issues.is_empty() {
resolved.raw.linear_until_complete && poll_results.last().is_some_and(|poll| poll.complete)
} else {
issues.iter().all(|issue| issue.success)
};
let result = LinearDeliveryResult {
provider: "linear".to_string(),
success,
duration_ms: started.elapsed().as_millis(),
issue_count: issues.len(),
phases,
watch,
poll_count,
until_complete: resolved.raw.linear_until_complete,
completion_gate,
state_file: paths.state_file.display().to_string(),
workspace_root: paths.workspace_root.display().to_string(),
observability_log: paths.events_file.display().to_string(),
polls: poll_results,
issues,
};
write_delivery_state(&paths.state_file, &mut state)?;
emit_event(
&context,
"delivery_completed",
json!({ "success": result.success, "issueCount": result.issue_count }),
)?;
Ok(result)
}
fn ensure_not_cancelled(context: &DeliveryEventContext) -> Result<(), String> {
if context
.cancel
.as_ref()
.is_some_and(|cancel| cancel.load(Ordering::Relaxed))
{
emit_event(
context,
"delivery_cancelled",
json!({ "reason": "cancel requested" }),
)?;
return Err("Linear delivery cancelled".to_string());
}
Ok(())
}
fn sleep_with_cancel(context: &DeliveryEventContext, duration: Duration) -> Result<(), String> {
let started = Instant::now();
while started.elapsed() < duration {
ensure_not_cancelled(context)?;
let remaining = duration.saturating_sub(started.elapsed());
thread::sleep(remaining.min(Duration::from_millis(250)));
}
Ok(())
}
fn run_linear_delivery_poll(
context: &DeliveryRunContext<'_>,
state: &mut DeliveryState,
poll: usize,
) -> Result<DeliveryPollResult, String> {
ensure_not_cancelled(context.events)?;
emit_event(
context.events,
"delivery_poll_started",
json!({ "poll": poll }),
)?;
ensure_not_cancelled(context.events)?;
let issues = fetch_linear_issues(context.resolved, context.auth)?;
reconcile_delivery_state(
state,
&issues,
context.events,
effective_linear_watch(context.resolved),
)?;
let max_attempts = effective_max_attempts(context.resolved);
let mut eligible = Vec::new();
for issue in &issues {
if should_dispatch_issue(issue, state, max_attempts) {
eligible.push(issue.clone());
}
if eligible.len() >= context.resolved.raw.linear_limit.max(1) {
break;
}
}
let skipped = issues.len().saturating_sub(eligible.len());
let issue_results = run_linear_issue_batch(context, state, eligible)?;
write_delivery_state(&context.events.paths.state_file, state)?;
let complete =
is_delivery_target_complete(context.resolved, &issues, state, context.completion_gate);
emit_event(
context.events,
"delivery_poll_completed",
json!({
"poll": poll,
"fetched": issues.len(),
"dispatched": issue_results.len(),
"skipped": skipped,
"complete": complete
}),
)?;
Ok(DeliveryPollResult {
poll,
fetched: issues.len(),
dispatched: issue_results.len(),
skipped,
complete,
issues: issue_results,
})
}
fn run_linear_issue_batch(
context: &DeliveryRunContext<'_>,
state: &mut DeliveryState,
eligible: Vec<LinearIssue>,
) -> Result<Vec<IssueDeliveryResult>, String> {
ensure_not_cancelled(context.events)?;
let concurrency = context.resolved.raw.linear_max_concurrency.max(1);
if concurrency == 1 {
let mut issue_results = Vec::new();
for issue in eligible {
ensure_not_cancelled(context.events)?;
issue_results.push(run_linear_issue_delivery(
context.resolved,
context.auth,
context.phases,
state,
context.workflow_policy,
context.events,
issue,
)?);
}
return Ok(issue_results);
}
let mut issue_results = Vec::new();
for chunk in eligible.chunks(concurrency) {
ensure_not_cancelled(context.events)?;
let mut items = Vec::with_capacity(chunk.len());
for issue in chunk.iter().cloned() {
items.push(start_linear_issue_delivery(
context.resolved,
state,
context.events,
issue,
)?);
}
let completed = thread::scope(|scope| {
let handles = items
.into_iter()
.map(|item| {
let resolved = context.resolved.clone();
let auth = context.auth.to_string();
let phases = context.phases.to_vec();
let workflow_policy = context.workflow_policy.to_string();
let events = context.events.clone();
scope.spawn(move || {
finish_linear_issue_delivery(
&resolved,
&auth,
&phases,
&workflow_policy,
&events,
item,
)
})
})
.collect::<Vec<_>>();
handles
.into_iter()
.map(|handle| {
handle
.join()
.map_err(|_| "Linear issue worker panicked.".to_string())
.and_then(|result| result)
})
.collect::<Result<Vec<_>, String>>()
})?;
for (result, issue_state) in completed {
ensure_not_cancelled(context.events)?;
state
.issues
.insert(issue_state.identifier.clone(), issue_state);
write_delivery_state(&context.events.paths.state_file, state)?;
issue_results.push(result);
}
}
Ok(issue_results)
}
fn run_linear_issue_delivery(
resolved: &ResolvedArgs,
auth: &str,
phases: &[String],
state: &mut DeliveryState,
workflow_policy: &str,
context: &DeliveryEventContext,
issue: LinearIssue,
) -> Result<IssueDeliveryResult, String> {
let item = start_linear_issue_delivery(resolved, state, context, issue)?;
let (result, issue_state) =
finish_linear_issue_delivery(resolved, auth, phases, workflow_policy, context, item)?;
state
.issues
.insert(issue_state.identifier.clone(), issue_state);
write_delivery_state(&context.paths.state_file, state)?;
Ok(result)
}
fn start_linear_issue_delivery(
resolved: &ResolvedArgs,
state: &mut DeliveryState,
context: &DeliveryEventContext,
issue: LinearIssue,
) -> Result<IssueWorkItem, String> {
let attempt = {
let issue_state = ensure_issue_state(state, &issue);
issue_state.status = "running".to_string();
issue_state.attempts += 1;
issue_state.last_attempt_at = Some(now_iso());
issue_state.next_retry_at = None;
issue_state.last_error = None;
issue_state.phases.clear();
issue_state.attempts
};
write_delivery_state(&context.paths.state_file, state)?;
emit_event(
context,
"delivery_issue_started",
json!({ "issue": &issue, "attempt": attempt }),
)?;
let workspace = prepare_issue_workspace(resolved, &issue, &context.paths)?;
let issue_state = {
let issue_state = ensure_issue_state(state, &issue);
issue_state.workspace = Some(workspace.cwd.clone());
issue_state.workspace_strategy = Some(workspace.strategy.clone());
issue_state.branch = workspace.branch.clone();
issue_state.clone()
};
write_delivery_state(&context.paths.state_file, state)?;
emit_event(
context,
"delivery_workspace_prepared",
json!({ "issue": &issue, "workspace": &workspace }),
)?;
Ok(IssueWorkItem {
issue,
workspace,
issue_state,
})
}
fn finish_linear_issue_delivery(
resolved: &ResolvedArgs,
auth: &str,
phases: &[String],
workflow_policy: &str,
context: &DeliveryEventContext,
mut item: IssueWorkItem,
) -> Result<(IssueDeliveryResult, IssueDeliveryState), String> {
let issue = item.issue;
let workspace = item.workspace;
let issue_state = &mut item.issue_state;
let max_attempts = effective_max_attempts(resolved);
let retry_delay_ms = resolved
.raw
.linear_retry_base
.saturating_mul(1000)
.max(1000);
let workspace_path = Path::new(&workspace.cwd);
let delivery_baseline = capture_workspace_snapshot(workspace_path);
let mut phase_results = Vec::new();
let mut conversation = Vec::<(String, String)>::new();
let mut issue_success = true;
let mut local_blocker = None;
for phase in phases {
ensure_not_cancelled(context)?;
emit_event(
context,
"delivery_phase_started",
json!({ "issue": &issue, "phase": phase }),
)?;
let git_before = capture_git_snapshot(workspace_path);
let mut phase_resolved = resolved.clone();
phase_resolved.cwd = PathBuf::from(&workspace.cwd);
phase_resolved.raw.cwd = phase_resolved.cwd.clone();
phase_resolved.raw.handoff = phase != "plan" || resolved.raw.handoff;
phase_resolved.raw.planner =
pick_phase_planner(phase, &resolved.members, resolved.raw.planner.as_deref());
phase_resolved.raw.lead =
pick_phase_lead(phase, &resolved.members, resolved.raw.lead.as_deref());
phase_resolved.prompt =
build_delivery_phase_prompt(phase, &issue, resolved, &conversation, workflow_policy);
let prompt_context =
build_prompt_context_with_progress(&phase_resolved, context.progress.clone())?;
let result = run_amon_hen_with_progress_and_cancel(
&phase_resolved,
prompt_context.prompt,
prompt_context.commands,
context.progress.clone(),
context.cancel.clone(),
);
let summary = result.summary.clone();
let git_after = capture_git_snapshot(workspace_path);
let phase_gates = evaluate_phase_gates(workspace_path, &git_before, &git_after);
let gate_blocker = first_blocked_gate(&phase_gates);
let success = summary.status == "ok" && gate_blocker.is_none();
let phase_result = PhaseDeliveryResult {
phase: phase.clone(),
summary_status: summary.status.clone(),
summarizer: summary.name.clone(),
output: summary.output.clone(),
git_before,
git_after,
gates: phase_gates.clone(),
};
phase_results.push(phase_result);
conversation.push((
phase.clone(),
if summary.output.trim().is_empty() {
summary.detail.clone()
} else {
summary.output.clone()
},
));
issue_state.phases.push(PhaseState {
phase: phase.clone(),
status: summary.status.clone(),
summarizer: Some(summary.name.clone()),
completed_at: now_iso(),
});
if !success {
issue_state.last_error = Some(if summary.detail.trim().is_empty() {
gate_blocker
.clone()
.unwrap_or_else(|| format!("{phase} failed"))
} else {
gate_blocker
.clone()
.unwrap_or_else(|| summary.detail.clone())
});
local_blocker = gate_blocker;
}
emit_event(
context,
"delivery_phase_completed",
json!({
"issue": &issue,
"phase": phase,
"success": success,
"summaryStatus": summary.status,
"summarizer": summary.name,
"gates": phase_gates
}),
)?;
if !success {
issue_success = false;
break;
}
}
let mut harness = if issue_success {
evaluate_delivery_harness(
workspace_path,
&delivery_baseline,
&issue,
issue_state,
&phase_results,
context.cancel.clone(),
)
} else {
DeliveryHarnessResult {
gates: vec![],
repo_changed: false,
blocked_detail: local_blocker,
no_op_detail: None,
}
};
if let Some(blocker) = harness.blocked_detail.clone() {
issue_success = false;
issue_state.last_error = Some(blocker);
} else if let Some(no_op) = harness.no_op_detail.clone() {
issue_success = false;
issue_state.last_error = Some(no_op);
}
let mut media_attachments = Vec::new();
if issue_success {
for media in &resolved.raw.linear_attach_media {
match attach_linear_media(resolved, auth, &issue, &workspace, media) {
Ok(media) => {
emit_event(
context,
"delivery_media_attached",
json!({ "issue": &issue, "media": &media }),
)?;
media_attachments.push(media);
}
Err(error) => {
emit_event(
context,
"delivery_media_attach_failed",
json!({ "issue": &issue, "media": media, "detail": error }),
)?;
media_attachments.push(MediaAttachmentResult {
source: media.clone(),
url: None,
attachment_id: None,
title: None,
error: Some(error),
});
}
}
}
}
let media_success = media_attachments
.iter()
.all(|attachment| attachment.error.is_none());
let completion = if issue_success && media_success {
evaluate_completion_gate(
resolved,
&issue,
Some(issue_state),
&workspace,
&phase_results,
&harness,
)
} else {
let (status, detail) = if let Some(blocker) = harness.blocked_detail.clone() {
("blocked".to_string(), blocker)
} else if let Some(no_op) = harness.no_op_detail.clone() {
("no_op".to_string(), no_op)
} else {
(
"retry_wait".to_string(),
if issue_success {
"One or more Linear media attachments failed.".to_string()
} else {
"One or more delivery phases failed.".to_string()
},
)
};
CompletionResult {
success: false,
status,
gate: normalize_completion_gate(&resolved.raw.linear_completion_gate),
detail,
pr_url: None,
gates: std::mem::take(&mut harness.gates),
repo_changed: harness.repo_changed,
}
};
let state_update = if completion.success && resolved.raw.linear_update_review_state {
resolved.raw.linear_review_state.as_ref().map(
|review_state| match update_linear_issue_state(resolved, auth, &issue, review_state) {
Ok(()) => StateUpdateResult {
state: review_state.clone(),
status: "ok".to_string(),
error: None,
},
Err(error) => StateUpdateResult {
state: review_state.clone(),
status: "error".to_string(),
error: Some(error),
},
},
)
} else {
None
};
if let Some(update) = &state_update {
emit_event(
context,
"delivery_issue_state_update",
json!({ "issue": &issue, "state": update.state, "status": update.status, "error": update.error }),
)?;
}
let mut comments = Vec::new();
if !resolved.raw.no_linear_comments {
let body = build_delivery_comment_body(
&issue,
&workspace,
&completion,
&phase_results,
&media_attachments,
state_update.as_ref(),
);
match create_linear_comment(resolved, auth, &issue.id, &body) {
Ok(comment) => {
emit_event(
context,
"delivery_comment_created",
json!({ "issue": &issue, "comment": &comment }),
)?;
comments.push(comment);
}
Err(error) => {
emit_event(
context,
"delivery_comment_failed",
json!({ "issue": &issue, "detail": error }),
)?;
comments.push(LinearCommentResult {
id: None,
url: None,
status: "error".to_string(),
error: Some(error),
});
}
}
}
if completion.success {
issue_state.status = completion.status.clone();
issue_state.completed_at = Some(now_iso());
issue_state.completion_gate = Some(completion.gate.clone());
issue_state.completion_detail = Some(completion.detail.clone());
issue_state.pr_url = completion
.pr_url
.clone()
.or_else(|| issue_state.pr_url.clone());
match issue_state.status.as_str() {
"delivered" => issue_state.delivered_at = issue_state.completed_at.clone(),
"review_ready" => issue_state.review_ready_at = issue_state.completed_at.clone(),
"ci_passed" => issue_state.ci_passed_at = issue_state.completed_at.clone(),
_ => {}
}
} else {
issue_state.last_error = Some(completion.detail.clone());
apply_failed_completion_state(issue_state, &completion, max_attempts, retry_delay_ms);
}
let result_issue_state = issue_state.clone();
emit_event(
context,
"delivery_issue_completed",
json!({
"issue": &issue,
"success": completion.success,
"status": result_issue_state.status,
"attempts": result_issue_state.attempts,
"nextRetryAt": result_issue_state.next_retry_at
}),
)?;
Ok((
IssueDeliveryResult {
issue,
success: completion.success,
status: result_issue_state.status.clone(),
workspace,
attempts: result_issue_state.attempts,
next_retry_at: result_issue_state.next_retry_at.clone(),
completion,
phases: phase_results,
media_attachments,
comments,
state_update,
},
result_issue_state,
))
}
pub(super) fn render_linear_delivery_result(result: &LinearDeliveryResult) -> String {
let mut lines = vec![
format!(
"Linear delivery: {} ({} task{}, {} poll{})",
if result.success {
"completed"
} else {
"needs attention"
},
result.issue_count,
if result.issue_count == 1 { "" } else { "s" },
result.poll_count,
if result.poll_count == 1 { "" } else { "s" }
),
format!("State: {}", result.state_file),
format!("Workspaces: {}", result.workspace_root),
format!("Observability: {}", result.observability_log),
format!("Completion gate: {}", result.completion_gate),
];
for issue in &result.issues {
lines.push(String::new());
lines.push(format!(
"=== {}: {} ===",
issue.issue.identifier, issue.issue.title
));
lines.push(format!(
"Status: {}",
if issue.success {
issue.status.as_str()
} else {
"needs attention"
}
));
if let Some(url) = &issue.issue.url {
lines.push(format!("Linear: {url}"));
}
lines.push(format!("Workspace: {}", issue.workspace.cwd));
if let Some(next_retry) = &issue.next_retry_at {
lines.push(format!("Next retry: {next_retry}"));
}
if !issue.completion.detail.trim().is_empty() {
lines.push(format!("Completion: {}", issue.completion.detail));
}
lines.push(format!("Repo changed: {}", issue.completion.repo_changed));
for phase in &issue.phases {
lines.push(format!(
"- {}: {} via {}",
phase.phase, phase.summary_status, phase.summarizer
));
for gate in phase.gates.iter().filter(|gate| gate.status != "ok") {
lines.push(format!(
" - gate {}: {} ({})",
gate.name, gate.status, gate.detail
));
}
}
for gate in issue
.completion
.gates
.iter()
.filter(|gate| gate.status != "ok")
{
lines.push(format!(
"- completion gate {}: {} ({})",
gate.name, gate.status, gate.detail
));
}
for media in &issue.media_attachments {
if let Some(error) = &media.error {
lines.push(format!("- media: failed {} ({error})", media.source));
} else {
lines.push(format!(
"- media: attached {} -> {}",
media.title.as_deref().unwrap_or(&media.source),
media.url.as_deref().unwrap_or("unknown")
));
}
}
for comment in &issue.comments {
if let Some(error) = &comment.error {
lines.push(format!("- linear comment: failed ({error})"));
} else {
lines.push(format!(
"- linear comment: {}",
comment.url.as_deref().unwrap_or("created")
));
}
}
if let Some(update) = &issue.state_update {
lines.push(format!(
"- linear state: {} ({})",
update.state, update.status
));
}
}
lines.join("\n")
}
fn fetch_linear_viewer(
resolved: &ResolvedArgs,
auth: &str,
) -> Result<Option<LinearViewer>, String> {
let data = linear_graphql(
resolved,
auth,
"query AmonHenLinearViewer { viewer { id name email } }",
json!({}),
)?;
Ok(data
.get("viewer")
.and_then(|value| serde_json::from_value::<LinearViewer>(value.clone()).ok()))
}
fn fetch_linear_issues(resolved: &ResolvedArgs, auth: &str) -> Result<Vec<LinearIssue>, String> {
if !resolved.raw.linear_issue.is_empty() {
let mut issues = Vec::new();
for id in &resolved.raw.linear_issue {
let data = linear_graphql(
resolved,
auth,
&format!(
"query AmonHenLinearIssue($id: String!) {{ issue(id: $id) {{ {} }} }}",
issue_fields()
),
json!({ "id": id }),
)?;
if let Some(issue) = data.get("issue").and_then(normalize_linear_issue) {
issues.push(issue);
}
}
return Ok(issues);
}
let mut all = Vec::new();
let mut after = Value::Null;
loop {
let variables = json!({
"first": resolved.raw.linear_limit.max(1),
"after": after,
"filter": build_issue_filter(resolved)
});
let data = linear_graphql(
resolved,
auth,
&format!(
"query AmonHenLinearIssues($first: Int!, $after: String, $filter: IssueFilter) {{ issues(first: $first, after: $after, filter: $filter) {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}",
issue_fields()
),
variables,
)?;
let connection = data.get("issues").cloned().unwrap_or(Value::Null);
if let Some(nodes) = connection.get("nodes").and_then(Value::as_array) {
all.extend(nodes.iter().filter_map(normalize_linear_issue));
}
let has_next = resolved.raw.linear_until_complete
&& connection
.pointer("/pageInfo/hasNextPage")
.and_then(Value::as_bool)
.unwrap_or(false);
if has_next {
after = connection
.pointer("/pageInfo/endCursor")
.cloned()
.unwrap_or(Value::Null);
} else {
break;
}
}
Ok(all)
}
fn linear_graphql(
resolved: &ResolvedArgs,
auth: &str,
query: &str,
variables: Value,
) -> Result<Value, String> {
let client = linear_client()?;
let response = client
.post(linear_endpoint(resolved))
.header("authorization", auth)
.json(&json!({ "query": query, "variables": variables }))
.send()
.map_err(|error| format!("Linear API request failed: {error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("Linear API request failed with HTTP {status}."));
}
let payload = response
.json::<Value>()
.map_err(|error| format!("Linear API response was not valid JSON: {error}"))?;
if let Some(errors) = payload.get("errors").and_then(Value::as_array) {
if !errors.is_empty() {
let message = errors
.iter()
.filter_map(|error| error.get("message").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("; ");
return Err(format!(
"Linear API request failed: {}",
if message.is_empty() {
"Unknown GraphQL error."
} else {
&message
}
));
}
}
Ok(payload.get("data").cloned().unwrap_or(Value::Null))
}
fn linear_client() -> Result<Client, String> {
static CLIENT: OnceLock<Result<Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|error| format!("Failed to create Linear HTTP client: {error}"))
})
.as_ref()
.cloned()
.map_err(Clone::clone)
}
fn issue_fields() -> &'static str {
"id identifier title description priority url branchName createdAt updatedAt project { id name slug url } parent { id identifier title url } state { name } team { key name } assignee { name email } labels { nodes { name } }"
}
fn build_issue_filter(resolved: &ResolvedArgs) -> Value {
let mut filter = Map::new();
if let Some(query) = resolved
.raw
.linear_query
.as_ref()
.filter(|value| !value.is_empty())
{
filter.insert(
"or".to_string(),
json!([
{ "identifier": { "eq": query } },
{ "title": { "containsIgnoreCase": query } },
{ "description": { "containsIgnoreCase": query } }
]),
);
}
if let Some(team) = resolved
.raw
.linear_team
.as_ref()
.filter(|value| !value.is_empty())
{
filter.insert("team".to_string(), json!({ "key": { "eq": team } }));
}
if let Some(state) = resolved
.raw
.linear_state
.as_ref()
.filter(|value| !value.is_empty())
{
filter.insert("state".to_string(), json!({ "name": { "eq": state } }));
}
if let Some(assignee) = resolved
.raw
.linear_assignee
.as_ref()
.filter(|value| !value.is_empty())
{
filter.insert(
"assignee".to_string(),
json!({ "or": [
{ "name": { "containsIgnoreCase": assignee } },
{ "email": { "containsIgnoreCase": assignee } }
] }),
);
}
if let Some(project) = entity_filter(&resolved.raw.linear_project, &["id", "slug"], &["name"]) {
filter.insert("project".to_string(), project);
}
if let Some(epic) = entity_filter(&resolved.raw.linear_epic, &["id", "identifier"], &["title"])
{
filter.insert("parent".to_string(), epic);
}
if filter.is_empty() {
Value::Null
} else {
Value::Object(filter)
}
}
fn entity_filter(values: &[String], exact: &[&str], text: &[&str]) -> Option<Value> {
let mut parts = Vec::new();
for value in normalize_values(values) {
for field in exact {
parts.push(json!({ field.to_string(): { "eq": value } }));
}
for field in text {
parts.push(json!({ field.to_string(): { "containsIgnoreCase": value } }));
}
}
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(json!({ "or": parts })),
}
}
fn normalize_linear_issue(value: &Value) -> Option<LinearIssue> {
Some(LinearIssue {
id: value.get("id")?.as_str()?.to_string(),
identifier: value.get("identifier")?.as_str()?.to_string(),
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
description: value
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
priority: value.get("priority").and_then(Value::as_i64),
url: string_at(value, "/url"),
branch_name: string_at(value, "/branchName"),
project: value.get("project").and_then(normalize_project),
epic: value.get("parent").and_then(normalize_epic),
state: string_at(value, "/state/name"),
team: string_at(value, "/team/key").or_else(|| string_at(value, "/team/name")),
assignee: string_at(value, "/assignee/name")
.or_else(|| string_at(value, "/assignee/email")),
labels: value
.pointer("/labels/nodes")
.and_then(Value::as_array)
.map(|labels| {
labels
.iter()
.filter_map(|label| label.get("name").and_then(Value::as_str))
.map(ToString::to_string)
.collect()
})
.unwrap_or_default(),
created_at: string_at(value, "/createdAt"),
updated_at: string_at(value, "/updatedAt"),
})
}
fn normalize_project(value: &Value) -> Option<LinearEntity> {
Some(LinearEntity {
id: string_at(value, "/id"),
name: string_at(value, "/name"),
slug: string_at(value, "/slug"),
identifier: None,
title: None,
url: string_at(value, "/url"),
})
}
fn normalize_epic(value: &Value) -> Option<LinearEntity> {
Some(LinearEntity {
id: string_at(value, "/id"),
name: None,
slug: None,
identifier: string_at(value, "/identifier"),
title: string_at(value, "/title"),
url: string_at(value, "/url"),
})
}
fn string_at(value: &Value, pointer: &str) -> Option<String> {
value
.pointer(pointer)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
}
fn attach_linear_media(
resolved: &ResolvedArgs,
auth: &str,
issue: &LinearIssue,
workspace: &IssueWorkspace,
media: &str,
) -> Result<MediaAttachmentResult, String> {
let source = media.trim();
if source.is_empty() {
return Err("Linear media source is empty.".to_string());
}
let is_remote = source.starts_with("http://") || source.starts_with("https://");
let (url, subtitle) = if is_remote {
(source.to_string(), "Attached by Amon Hen".to_string())
} else {
let uploaded = upload_linear_file(resolved, auth, &workspace.cwd, source, issue)?;
(
uploaded.asset_url,
format!("Uploaded {}", uploaded.filename),
)
};
let title = resolved
.raw
.linear_attachment_title
.as_ref()
.map(|prefix| format!("{prefix}: {}", media_title(source)))
.unwrap_or_else(|| media_title(source));
let attachment = create_linear_attachment(resolved, auth, &issue.id, &title, &url, &subtitle)?;
Ok(MediaAttachmentResult {
source: source.to_string(),
url: Some(url),
attachment_id: attachment
.get("id")
.and_then(Value::as_str)
.map(ToString::to_string),
title: attachment
.get("title")
.and_then(Value::as_str)
.map(ToString::to_string),
error: None,
})
}
struct UploadedFile {
filename: String,
asset_url: String,
}
fn upload_linear_file(
resolved: &ResolvedArgs,
auth: &str,
cwd: &str,
file_path: &str,
issue: &LinearIssue,
) -> Result<UploadedFile, String> {
let path = absolutize(Path::new(cwd), Path::new(file_path));
let metadata = fs::metadata(&path)
.map_err(|error| format!("Failed to read Linear media {}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!(
"Linear media path is not a file: {}",
path.display()
));
}
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("attachment")
.to_string();
let content_type = infer_content_type(&filename);
let data = linear_graphql(
resolved,
auth,
"mutation AmonHenLinearFileUpload($contentType: String!, $filename: String!, $size: Int!, $makePublic: Boolean, $metaData: JSON) { fileUpload(contentType: $contentType, filename: $filename, size: $size, makePublic: $makePublic, metaData: $metaData) { success uploadFile { uploadUrl assetUrl headers { key value } } } }",
json!({
"contentType": content_type,
"filename": filename,
"size": metadata.len() as i64,
"makePublic": false,
"metaData": { "issueId": issue.id, "source": "amon-hen" }
}),
)?;
let upload = data
.get("fileUpload")
.ok_or_else(|| "Linear fileUpload did not return a payload.".to_string())?;
if !upload
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err("Linear fileUpload did not succeed.".to_string());
}
let upload_file = upload
.get("uploadFile")
.ok_or_else(|| "Linear fileUpload did not return an uploadFile.".to_string())?;
let upload_url = upload_file
.get("uploadUrl")
.and_then(Value::as_str)
.ok_or_else(|| "Linear fileUpload did not return uploadUrl.".to_string())?;
let asset_url = upload_file
.get("assetUrl")
.and_then(Value::as_str)
.ok_or_else(|| "Linear fileUpload did not return assetUrl.".to_string())?;
let bytes = fs::read(&path)
.map_err(|error| format!("Failed to read Linear media {}: {error}", path.display()))?;
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|error| format!("Failed to create upload client: {error}"))?;
let mut request = client
.put(upload_url)
.header("content-type", content_type)
.header("cache-control", "public, max-age=31536000");
if let Some(headers) = upload_file.get("headers").and_then(Value::as_array) {
for header in headers {
if let (Some(key), Some(value)) = (
header.get("key").and_then(Value::as_str),
header.get("value").and_then(Value::as_str),
) {
request = request.header(key, value);
}
}
}
let response = request
.body(bytes)
.send()
.map_err(|error| format!("Linear media upload failed: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"Linear media upload failed with HTTP {}.",
response.status()
));
}
Ok(UploadedFile {
filename,
asset_url: asset_url.to_string(),
})
}
fn create_linear_attachment(
resolved: &ResolvedArgs,
auth: &str,
issue_id: &str,
title: &str,
url: &str,
subtitle: &str,
) -> Result<Value, String> {
let data = linear_graphql(
resolved,
auth,
"mutation AmonHenLinearAttachmentCreate($input: AttachmentCreateInput!) { attachmentCreate(input: $input) { success attachment { id title subtitle url } } }",
json!({
"input": {
"issueId": issue_id,
"title": title,
"url": url,
"subtitle": subtitle,
"metadata": { "source": "amon-hen" }
}
}),
)?;
let payload = data
.get("attachmentCreate")
.ok_or_else(|| "Linear attachmentCreate did not return a payload.".to_string())?;
if !payload
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err("Linear attachmentCreate did not succeed.".to_string());
}
payload
.get("attachment")
.cloned()
.ok_or_else(|| "Linear attachmentCreate did not return an attachment.".to_string())
}
fn create_linear_comment(
resolved: &ResolvedArgs,
auth: &str,
issue_id: &str,
body: &str,
) -> Result<LinearCommentResult, String> {
let data = linear_graphql(
resolved,
auth,
"mutation AmonHenLinearCommentCreate($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id url } } }",
json!({
"input": {
"issueId": issue_id,
"body": body
}
}),
)?;
let payload = data
.get("commentCreate")
.ok_or_else(|| "Linear commentCreate did not return a payload.".to_string())?;
if !payload
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err("Linear commentCreate did not succeed.".to_string());
}
let comment = payload.get("comment").cloned().unwrap_or(Value::Null);
Ok(LinearCommentResult {
id: comment
.get("id")
.and_then(Value::as_str)
.map(ToString::to_string),
url: comment
.get("url")
.and_then(Value::as_str)
.map(ToString::to_string),
status: "ok".to_string(),
error: None,
})
}
fn update_linear_issue_state(
resolved: &ResolvedArgs,
auth: &str,
issue: &LinearIssue,
review_state: &str,
) -> Result<(), String> {
let mut filter = json!({ "name": { "eq": review_state } });
if let Some(team) = &issue.team {
filter = json!({
"and": [
{ "name": { "eq": review_state } },
{ "team": { "key": { "eq": team } } }
]
});
}
let data = linear_graphql(
resolved,
auth,
"query AmonHenLinearWorkflowState($filter: WorkflowStateFilter) { workflowStates(first: 10, filter: $filter) { nodes { id name } } }",
json!({ "filter": filter }),
)?;
let state_id = data
.pointer("/workflowStates/nodes")
.and_then(Value::as_array)
.and_then(|nodes| nodes.first())
.and_then(|node| node.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| format!("Could not find Linear workflow state `{review_state}`."))?;
let updated = linear_graphql(
resolved,
auth,
"mutation AmonHenLinearIssueState($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id } } }",
json!({
"id": issue.id,
"input": { "stateId": state_id }
}),
)?;
let success = updated
.pointer("/issueUpdate/success")
.and_then(Value::as_bool)
.unwrap_or(false);
if success {
Ok(())
} else {
Err("Linear issueUpdate did not succeed.".to_string())
}
}
fn build_delivery_comment_body(
issue: &LinearIssue,
workspace: &IssueWorkspace,
completion: &CompletionResult,
phases: &[PhaseDeliveryResult],
media: &[MediaAttachmentResult],
state_update: Option<&StateUpdateResult>,
) -> String {
let mut lines = vec![
format!("Amon Hen delivery update for {}", issue.identifier),
String::new(),
format!("Status: {}", completion.status),
format!("Gate: {}", completion.gate),
format!("Repo changed: {}", completion.repo_changed),
format!("Workspace strategy: {}", workspace.strategy),
];
if let Some(branch) = &workspace.branch {
lines.push(format!("Branch: {branch}"));
}
if let Some(pr_url) = &completion.pr_url {
lines.push(format!("PR: {pr_url}"));
}
if !completion.detail.trim().is_empty() {
lines.push(format!("Detail: {}", completion.detail));
}
lines.push(String::new());
lines.push("Phases:".to_string());
for phase in phases {
lines.push(format!(
"- {}: {} via {}",
phase.phase, phase.summary_status, phase.summarizer
));
for gate in phase.gates.iter().filter(|gate| gate.status != "ok") {
lines.push(format!(" - gate {}: {}", gate.name, gate.status));
}
}
if !completion.gates.is_empty() {
lines.push(String::new());
lines.push("Delivery gates:".to_string());
for gate in &completion.gates {
lines.push(format!(
"- {}: {} - {}",
gate.name, gate.status, gate.detail
));
}
}
if !media.is_empty() {
lines.push(String::new());
lines.push("Media:".to_string());
for item in media {
lines.push(format!(
"- {}: {}",
item.source,
item.error.as_deref().unwrap_or("attached")
));
}
}
if let Some(update) = state_update {
lines.push(String::new());
lines.push(format!(
"Review state update: {} ({})",
update.state, update.status
));
}
truncate(&lines.join("\n"), 16_000)
}
fn capture_workspace_snapshot(cwd: &Path) -> WorkspaceSnapshot {
WorkspaceSnapshot {
git: capture_git_snapshot(cwd),
fingerprint: workspace_fingerprint(cwd),
}
}
fn capture_git_snapshot(cwd: &Path) -> GitSnapshot {
let inside_args = ["rev-parse".to_string(), "--is-inside-work-tree".to_string()];
let inside = run_command(CommandRequest::new("git", &inside_args, cwd, 30_000));
if inside.error.is_some() {
return GitSnapshot {
available: false,
inside_work_tree: false,
branch: None,
head: None,
status: String::new(),
diff_name_status: String::new(),
detail: "Git is not installed or not on PATH.".to_string(),
};
}
if inside.code != Some(0) || inside.stdout.trim() != "true" {
return GitSnapshot {
available: true,
inside_work_tree: false,
branch: None,
head: None,
status: String::new(),
diff_name_status: String::new(),
detail: "Workspace is not a git work tree.".to_string(),
};
}
let branch = git_stdout(cwd, &["branch", "--show-current"]);
let head = git_stdout(cwd, &["rev-parse", "HEAD"]);
let status =
git_stdout(cwd, &["status", "--porcelain=v1", "--untracked-files=all"]).unwrap_or_default();
let diff_name_status =
git_stdout(cwd, &["diff", "--name-status", "--no-ext-diff"]).unwrap_or_default();
GitSnapshot {
available: true,
inside_work_tree: true,
branch,
head,
status,
diff_name_status,
detail: "Git state captured.".to_string(),
}
}
fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
let args = args
.iter()
.map(|arg| (*arg).to_string())
.collect::<Vec<_>>();
let result = run_command(CommandRequest::new("git", &args, cwd, 30_000));
if result.code == Some(0) {
Some(result.stdout.trim().to_string())
} else {
None
}
}
fn workspace_fingerprint(cwd: &Path) -> Vec<String> {
let mut entries = Vec::new();
collect_workspace_fingerprint(cwd, cwd, &mut entries, 0);
entries.sort();
entries
}
fn collect_workspace_fingerprint(
root: &Path,
path: &Path,
entries: &mut Vec<String>,
depth: usize,
) {
if depth > 16 {
return;
}
let Ok(read_dir) = fs::read_dir(path) else {
return;
};
for entry in read_dir.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if should_skip_delivery_scan_path(&path, &name) {
continue;
}
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.is_dir() {
collect_workspace_fingerprint(root, &path, entries, depth + 1);
continue;
}
if !metadata.is_file() || metadata.len() > 1_000_000 {
continue;
}
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
let digest = fs::read(&path)
.map(|bytes| {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
})
.unwrap_or(0);
entries.push(format!("{relative}:{}:{digest:x}", metadata.len()));
}
}
fn evaluate_phase_gates(
cwd: &Path,
before: &GitSnapshot,
after: &GitSnapshot,
) -> Vec<DeliveryGateResult> {
let mut gates = Vec::new();
gates.push(git_state_gate(before, after));
gates.extend(scan_workspace_for_sensitive_evidence(cwd));
gates
}
fn evaluate_delivery_harness(
cwd: &Path,
baseline: &WorkspaceSnapshot,
issue: &LinearIssue,
issue_state: &IssueDeliveryState,
phases: &[PhaseDeliveryResult],
cancel: Option<Arc<AtomicBool>>,
) -> DeliveryHarnessResult {
let final_snapshot = capture_workspace_snapshot(cwd);
let mut gates = Vec::new();
let repo_changed = workspace_snapshot_changed(baseline, &final_snapshot);
gates.push(change_detection_gate(
repo_changed,
baseline,
&final_snapshot,
));
gates.extend(scan_workspace_for_sensitive_evidence(cwd));
gates.extend(run_delivery_validation_commands(cwd, cancel));
let pr_url = collect_pr_url(
issue,
Some(issue_state),
&IssueWorkspace {
cwd: cwd.display().to_string(),
strategy: issue_state
.workspace_strategy
.clone()
.unwrap_or_else(|| "unknown".to_string()),
branch: issue_state.branch.clone(),
},
phases,
);
let blocked_detail = first_blocked_gate(&gates);
let no_op_detail = if !repo_changed && pr_url.is_none() {
Some(
"No concrete repository change or GitHub PR was produced by the delivery phases."
.to_string(),
)
} else {
None
};
DeliveryHarnessResult {
gates,
repo_changed,
blocked_detail,
no_op_detail,
}
}
fn workspace_snapshot_changed(before: &WorkspaceSnapshot, after: &WorkspaceSnapshot) -> bool {
if before.git.inside_work_tree && after.git.inside_work_tree {
before.git.head != after.git.head
|| before.git.status != after.git.status
|| before.git.diff_name_status != after.git.diff_name_status
} else {
before.fingerprint != after.fingerprint
}
}
fn git_state_gate(before: &GitSnapshot, after: &GitSnapshot) -> DeliveryGateResult {
if !after.available || !after.inside_work_tree {
return DeliveryGateResult {
name: "git-state".to_string(),
status: "skipped".to_string(),
detail: after.detail.clone(),
command: None,
exit_code: None,
duration_ms: None,
};
}
let dirty = !after.status.trim().is_empty();
let head_changed = before.head != after.head;
let detail = if dirty && head_changed {
"Git HEAD changed and the workspace has an uncommitted diff.".to_string()
} else if dirty {
"Workspace has an uncommitted diff.".to_string()
} else if head_changed {
"Git HEAD changed and workspace is clean.".to_string()
} else {
"Git state unchanged and workspace is clean.".to_string()
};
DeliveryGateResult {
name: "git-state".to_string(),
status: "ok".to_string(),
detail,
command: None,
exit_code: None,
duration_ms: None,
}
}
fn change_detection_gate(
repo_changed: bool,
baseline: &WorkspaceSnapshot,
final_snapshot: &WorkspaceSnapshot,
) -> DeliveryGateResult {
let detail = if repo_changed {
"Repository or workspace content changed during delivery.".to_string()
} else if baseline.git.inside_work_tree && final_snapshot.git.inside_work_tree {
"No git HEAD, status, or diff change was detected.".to_string()
} else {
"No workspace file content change was detected.".to_string()
};
DeliveryGateResult {
name: "change-detection".to_string(),
status: if repo_changed { "ok" } else { "no_op" }.to_string(),
detail,
command: None,
exit_code: None,
duration_ms: None,
}
}
fn scan_workspace_for_sensitive_evidence(cwd: &Path) -> Vec<DeliveryGateResult> {
let mut findings = Vec::new();
let git = capture_git_snapshot(cwd);
if git.inside_work_tree {
for path in changed_git_paths(&git.status) {
let path = cwd.join(path);
collect_sensitive_findings_for_path(cwd, &path, &mut findings, 0);
if findings.len() >= 12 {
break;
}
}
} else {
collect_sensitive_findings(cwd, cwd, &mut findings, 0);
}
if findings.is_empty() {
vec![DeliveryGateResult {
name: "secret-local-path-scan".to_string(),
status: "ok".to_string(),
detail: "No obvious secrets or local machine paths were detected in changed workspace files.".to_string(),
command: None,
exit_code: None,
duration_ms: None,
}]
} else {
vec![DeliveryGateResult {
name: "secret-local-path-scan".to_string(),
status: "blocked".to_string(),
detail: format!(
"Potential secret or local path evidence detected: {}.",
findings.join("; ")
),
command: None,
exit_code: None,
duration_ms: None,
}]
}
}
fn changed_git_paths(status: &str) -> Vec<PathBuf> {
status
.lines()
.filter_map(|line| {
let path = line.get(3..)?.trim();
let path = path.rsplit(" -> ").next().unwrap_or(path).trim();
(!path.is_empty()).then(|| PathBuf::from(path))
})
.collect()
}
fn collect_sensitive_findings(root: &Path, path: &Path, findings: &mut Vec<String>, depth: usize) {
if findings.len() >= 12 || depth > 16 {
return;
}
let Ok(read_dir) = fs::read_dir(path) else {
return;
};
for entry in read_dir.flatten() {
if findings.len() >= 12 {
return;
}
let path = entry.path();
collect_sensitive_findings_for_path(root, &path, findings, depth);
}
}
fn collect_sensitive_findings_for_path(
root: &Path,
path: &Path,
findings: &mut Vec<String>,
depth: usize,
) {
if findings.len() >= 12 {
return;
}
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if should_skip_delivery_scan_path(path, name) {
return;
}
let Ok(metadata) = fs::symlink_metadata(path) else {
return;
};
if metadata.is_dir() {
collect_sensitive_findings(root, path, findings, depth + 1);
return;
}
if !metadata.is_file() || metadata.len() > 1_000_000 {
return;
}
let Ok(text) = fs::read_to_string(path) else {
return;
};
if let Some(reason) = sensitive_text_reason(&text) {
let relative = path.strip_prefix(root).unwrap_or(path).display();
findings.push(format!("{relative} ({reason})"));
}
}
fn sensitive_text_reason(text: &str) -> Option<&'static str> {
let lower = text.to_ascii_lowercase();
if lower.contains("-----begin ") && lower.contains(" private key-----") {
return Some("private key");
}
if lower.contains("aws_secret_access_key")
|| lower.contains("linear_api_key=")
|| lower.contains("github_token=")
|| lower.contains("ghp_")
|| lower.contains("sk-")
{
return Some("token-like secret");
}
if text.contains("/Users/") || text.contains("/home/") || text.contains("C:\\Users\\") {
return Some("local absolute path");
}
None
}
fn should_skip_delivery_scan_path(path: &Path, name: &str) -> bool {
if matches!(
name,
".git" | ".amon-hen" | "node_modules" | "dist" | "target" | ".next" | "coverage"
) {
return true;
}
path.components().any(|component| {
let text = component.as_os_str().to_string_lossy();
matches!(
text.as_ref(),
".git" | ".amon-hen" | "node_modules" | "dist" | "target" | ".next" | "coverage"
)
})
}
fn run_delivery_validation_commands(
cwd: &Path,
cancel: Option<Arc<AtomicBool>>,
) -> Vec<DeliveryGateResult> {
let mut commands = detect_delivery_validation_commands(cwd);
if commands.is_empty() {
return vec![DeliveryGateResult {
name: "test-build-capture".to_string(),
status: "skipped".to_string(),
detail: "No configured Cargo or npm test/build commands were detected.".to_string(),
command: None,
exit_code: None,
duration_ms: None,
}];
}
commands
.drain(..)
.map(|(name, command, args)| {
let result = run_command(
CommandRequest::new(&command, &args, cwd, 600_000).cancel(cancel.clone()),
);
let mut gate = command_gate(&name, &result);
gate.detail = truncate(&gate.detail, 1_200);
gate
})
.collect()
}
fn detect_delivery_validation_commands(cwd: &Path) -> Vec<(String, String, Vec<String>)> {
let mut commands = Vec::new();
if cwd.join("Cargo.toml").exists() {
commands.push((
"cargo-test".to_string(),
"cargo".to_string(),
vec!["test".to_string(), "--all".to_string()],
));
commands.push((
"cargo-build".to_string(),
"cargo".to_string(),
vec!["build".to_string(), "--all".to_string()],
));
}
if let Ok(text) = fs::read_to_string(cwd.join("package.json")) {
let scripts = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|value| value.get("scripts").cloned())
.unwrap_or(Value::Null);
if scripts.get("test").and_then(Value::as_str).is_some() {
commands.push((
"npm-test".to_string(),
"npm".to_string(),
vec![
"run".to_string(),
"test".to_string(),
"--if-present".to_string(),
],
));
}
if scripts.get("build").and_then(Value::as_str).is_some() {
commands.push((
"npm-build".to_string(),
"npm".to_string(),
vec![
"run".to_string(),
"build".to_string(),
"--if-present".to_string(),
],
));
}
}
commands
}
fn command_gate(name: &str, result: &CommandResult) -> DeliveryGateResult {
let status = if result.error.is_some() || result.timed_out || result.code != Some(0) {
"blocked"
} else {
"ok"
};
DeliveryGateResult {
name: name.to_string(),
status: status.to_string(),
detail: if status == "ok" {
"Command completed successfully.".to_string()
} else {
compact_failure(result)
},
command: Some(format_command(&result.command, &result.args)),
exit_code: result.code,
duration_ms: Some(result.duration_ms),
}
}
fn first_blocked_gate(gates: &[DeliveryGateResult]) -> Option<String> {
gates
.iter()
.find(|gate| gate.status == "blocked")
.map(|gate| format!("{} blocked delivery: {}", gate.name, gate.detail))
}
fn delivery_success_detail(harness: &DeliveryHarnessResult, base: &str) -> String {
let passed = harness
.gates
.iter()
.filter(|gate| gate.status == "ok")
.map(|gate| gate.name.as_str())
.collect::<Vec<_>>();
if passed.is_empty() {
base.to_string()
} else {
format!("{base} Passing gates: {}.", passed.join(", "))
}
}
fn evaluate_completion_gate(
resolved: &ResolvedArgs,
issue: &LinearIssue,
issue_state: Option<&IssueDeliveryState>,
workspace: &IssueWorkspace,
phases: &[PhaseDeliveryResult],
harness: &DeliveryHarnessResult,
) -> CompletionResult {
let gate = normalize_completion_gate(&resolved.raw.linear_completion_gate);
let pr_url = collect_pr_url(issue, issue_state, workspace, phases);
if let Some(detail) = &harness.blocked_detail {
return CompletionResult {
success: false,
status: "blocked".to_string(),
gate,
detail: detail.clone(),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
if gate == "delivered" {
if let Some(detail) = &harness.no_op_detail {
return CompletionResult {
success: false,
status: "no_op".to_string(),
gate,
detail: detail.clone(),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
return CompletionResult {
success: true,
status: "delivered".to_string(),
gate,
detail: delivery_success_detail(harness, "All delivery phases completed."),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
if matches!(gate.as_str(), "human-review" | "review-or-ci") {
if let Some(review_state) = &resolved.raw.linear_review_state {
if issue
.state
.as_deref()
.is_some_and(|state| same_text(state, review_state))
{
return CompletionResult {
success: true,
status: "review_ready".to_string(),
gate,
detail: delivery_success_detail(
harness,
&format!("Linear state is {review_state}."),
),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
}
if let Some(url) = pr_url.as_ref() {
return CompletionResult {
success: true,
status: "review_ready".to_string(),
gate,
detail: delivery_success_detail(
harness,
&format!("GitHub PR is ready for review: {url}."),
),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
if let Some(detail) = &harness.no_op_detail {
return CompletionResult {
success: false,
status: "no_op".to_string(),
gate,
detail: detail.clone(),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
if harness.repo_changed {
if let Some(branch) = workspace.branch.as_ref() {
return CompletionResult {
success: true,
status: "review_ready".to_string(),
gate,
detail: delivery_success_detail(
harness,
&format!("Branch has local delivery changes ready for review: {branch}."),
),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
}
if gate == "human-review" {
return CompletionResult {
success: false,
status: "blocked".to_string(),
gate,
detail: "Human-review gate requires a PR URL, matching Linear review state, or a changed review branch.".to_string(),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
}
if let Some(detail) = &harness.no_op_detail {
return CompletionResult {
success: false,
status: "no_op".to_string(),
gate,
detail: detail.clone(),
pr_url,
gates: harness.gates.clone(),
repo_changed: harness.repo_changed,
};
}
let mut completion = check_github_ci(
Path::new(&workspace.cwd),
pr_url
.clone()
.or_else(|| workspace.branch.clone())
.unwrap_or_default(),
&gate,
resolved.raw.linear_ci_timeout,
resolved.raw.linear_ci_poll_interval,
);
completion.gates.extend(harness.gates.clone());
completion.repo_changed = harness.repo_changed;
completion
}
fn check_github_ci(
cwd: &Path,
selector: String,
gate: &str,
timeout_secs: u64,
poll_secs: u64,
) -> CompletionResult {
if selector.trim().is_empty() {
return CompletionResult {
success: false,
status: "retry_wait".to_string(),
gate: gate.to_string(),
detail: "No GitHub PR URL or branch was found for CI checks.".to_string(),
pr_url: None,
gates: vec![],
repo_changed: false,
};
}
let started = Instant::now();
loop {
let args = vec![
"pr".to_string(),
"checks".to_string(),
selector.clone(),
"--json".to_string(),
"name,bucket,state,workflow,completedAt,link".to_string(),
];
let result = run_command(CommandRequest::new("gh", &args, cwd, 120_000));
let interpreted = interpret_github_checks(&result, &selector, gate);
if interpreted.success
|| result.code != Some(8)
|| timeout_secs == 0
|| started.elapsed() >= Duration::from_secs(timeout_secs)
{
return interpreted;
}
thread::sleep(Duration::from_secs(poll_secs.max(1)));
}
}
fn interpret_github_checks(result: &CommandResult, selector: &str, gate: &str) -> CompletionResult {
if result.error.is_some() {
return CompletionResult {
success: false,
status: "retry_wait".to_string(),
gate: gate.to_string(),
detail: "GitHub CLI (`gh`) is not installed or not on PATH.".to_string(),
pr_url: extract_github_pr_url(selector),
gates: vec![command_gate("github-ci", result)],
repo_changed: false,
};
}
let checks = serde_json::from_str::<Vec<Value>>(&result.stdout).unwrap_or_default();
if checks.is_empty() {
return CompletionResult {
success: false,
status: "retry_wait".to_string(),
gate: gate.to_string(),
detail: compact_failure(result),
pr_url: extract_github_pr_url(selector),
gates: vec![command_gate("github-ci", result)],
repo_changed: false,
};
}
let failed = checks
.iter()
.filter(|check| {
check
.get("bucket")
.and_then(Value::as_str)
.is_some_and(|bucket| matches!(bucket, "fail" | "cancel"))
})
.filter_map(|check| check.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
if !failed.is_empty() {
return CompletionResult {
success: false,
status: "blocked".to_string(),
gate: gate.to_string(),
detail: format!("GitHub checks failed: {}.", failed.join(", ")),
pr_url: extract_github_pr_url(selector),
gates: vec![command_gate("github-ci", result)],
repo_changed: false,
};
}
let passed = checks.iter().all(|check| {
check
.get("bucket")
.and_then(Value::as_str)
.is_some_and(|bucket| matches!(bucket, "pass" | "skipping"))
});
if passed {
CompletionResult {
success: true,
status: "ci_passed".to_string(),
gate: gate.to_string(),
detail: format!("GitHub checks passed for {selector}."),
pr_url: extract_github_pr_url(selector),
gates: vec![command_gate("github-ci", result)],
repo_changed: false,
}
} else {
CompletionResult {
success: false,
status: "retry_wait".to_string(),
gate: gate.to_string(),
detail: format!("GitHub checks are not complete for {selector}."),
pr_url: extract_github_pr_url(selector),
gates: vec![command_gate("github-ci", result)],
repo_changed: false,
}
}
}
fn collect_pr_url(
issue: &LinearIssue,
issue_state: Option<&IssueDeliveryState>,
workspace: &IssueWorkspace,
phases: &[PhaseDeliveryResult],
) -> Option<String> {
let mut text = String::new();
if let Some(url) = &issue.url {
text.push_str(url);
text.push('\n');
}
if let Some(url) = issue_state.and_then(|state| state.pr_url.as_ref()) {
text.push_str(url);
text.push('\n');
}
if let Some(branch) = &workspace.branch {
text.push_str(branch);
text.push('\n');
}
for phase in phases {
text.push_str(&phase.output);
text.push('\n');
}
extract_github_pr_url(&text)
}
fn extract_github_pr_url(text: &str) -> Option<String> {
text.split_whitespace()
.map(|token| {
token.trim_matches(|ch: char| matches!(ch, ',' | '.' | ')' | ']' | '"' | '\''))
})
.find(|token| {
token.starts_with("https://github.com/") && token.split('/').any(|part| part == "pull")
})
.map(ToString::to_string)
}
fn prepare_issue_workspace(
resolved: &ResolvedArgs,
issue: &LinearIssue,
paths: &DeliveryPaths,
) -> Result<IssueWorkspace, String> {
let strategy = resolved.raw.linear_workspace_strategy.trim();
if strategy == "none" {
return Ok(IssueWorkspace {
cwd: resolved.cwd.display().to_string(),
strategy: "none".to_string(),
branch: None,
});
}
let workspace_name = safe_workspace_name(&issue.identifier);
let workspace_path = paths.workspace_root.join(&workspace_name);
let branch = format!("{DEFAULT_BRANCH_PREFIX}{workspace_name}");
fs::create_dir_all(&paths.workspace_root).map_err(|error| {
format!(
"Failed to create workspace root {}: {error}",
paths.workspace_root.display()
)
})?;
if workspace_path.exists() {
return Ok(IssueWorkspace {
cwd: workspace_path.display().to_string(),
strategy: strategy.to_string(),
branch: Some(branch),
});
}
if strategy == "worktree" {
let args = vec![
"worktree".to_string(),
"add".to_string(),
"-B".to_string(),
branch.clone(),
workspace_path.display().to_string(),
"HEAD".to_string(),
];
let result = run_command(CommandRequest::new("git", &args, &resolved.cwd, 120_000));
if result.code == Some(0) {
return Ok(IssueWorkspace {
cwd: workspace_path.display().to_string(),
strategy: "worktree".to_string(),
branch: Some(branch),
});
}
}
copy_workspace(&resolved.cwd, &workspace_path)?;
Ok(IssueWorkspace {
cwd: workspace_path.display().to_string(),
strategy: "copy".to_string(),
branch: None,
})
}
fn copy_workspace(from: &Path, to: &Path) -> Result<(), String> {
fs::create_dir_all(to)
.map_err(|error| format!("Failed to create workspace {}: {error}", to.display()))?;
for entry in fs::read_dir(from)
.map_err(|error| format!("Failed to read workspace {}: {error}", from.display()))?
{
let entry = entry.map_err(|error| format!("Failed to read workspace entry: {error}"))?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if matches!(
name_str.as_ref(),
".git" | ".amon-hen" | "node_modules" | "dist" | "target"
) {
continue;
}
copy_path(&entry.path(), &to.join(name))?;
}
Ok(())
}
fn copy_path(from: &Path, to: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(from)
.map_err(|error| format!("Failed to inspect {}: {error}", from.display()))?;
if metadata.is_dir() {
fs::create_dir_all(to)
.map_err(|error| format!("Failed to create directory {}: {error}", to.display()))?;
for entry in fs::read_dir(from)
.map_err(|error| format!("Failed to read directory {}: {error}", from.display()))?
{
let entry =
entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
copy_path(&entry.path(), &to.join(entry.file_name()))?;
}
} else if metadata.file_type().is_symlink() {
let target = fs::read_link(from)
.map_err(|error| format!("Failed to read symlink {}: {error}", from.display()))?;
#[cfg(unix)]
std::os::unix::fs::symlink(&target, to)
.map_err(|error| format!("Failed to copy symlink {}: {error}", to.display()))?;
#[cfg(not(unix))]
fs::copy(from, to)
.map_err(|error| format!("Failed to copy symlink {}: {error}", from.display()))?;
} else {
fs::copy(from, to).map_err(|error| {
format!(
"Failed to copy {} to {}: {error}",
from.display(),
to.display()
)
})?;
}
Ok(())
}
fn should_dispatch_issue(issue: &LinearIssue, state: &DeliveryState, max_attempts: usize) -> bool {
let Some(issue_state) = state.issues.get(&issue.identifier) else {
return true;
};
if is_terminal_status(&issue_state.status, "delivered") || issue_state.status == "running" {
return false;
}
if issue_state.attempts >= max_attempts && issue_state.status == "failed" {
return false;
}
if let Some(next_retry) = &issue_state.next_retry_at {
if DateTime::parse_from_rfc3339(next_retry)
.map(|date| date.with_timezone(&Utc) > Utc::now())
.unwrap_or(false)
{
return false;
}
}
true
}
fn is_delivery_target_complete(
resolved: &ResolvedArgs,
issues: &[LinearIssue],
state: &DeliveryState,
completion_gate: &str,
) -> bool {
if !resolved.raw.linear_until_complete {
return false;
}
if issues.is_empty() {
return has_scoped_linear_target(resolved);
}
issues.iter().all(|issue| {
state
.issues
.get(&issue.identifier)
.is_some_and(|issue_state| is_terminal_status(&issue_state.status, completion_gate))
})
}
fn reconcile_delivery_state(
state: &mut DeliveryState,
issues: &[LinearIssue],
context: &DeliveryEventContext,
enabled: bool,
) -> Result<(), String> {
if !enabled {
return Ok(());
}
let active = issues
.iter()
.map(|issue| issue.identifier.as_str())
.collect::<std::collections::HashSet<_>>();
for issue_state in state.issues.values_mut() {
if !active.contains(issue_state.identifier.as_str())
&& matches!(
issue_state.status.as_str(),
"queued" | "running" | "retry_wait"
)
{
issue_state.status = "ineligible".to_string();
emit_event(
context,
"delivery_reconciled",
json!({ "identifier": issue_state.identifier, "status": "ineligible" }),
)?;
}
}
Ok(())
}
fn ensure_issue_state<'a>(
state: &'a mut DeliveryState,
issue: &LinearIssue,
) -> &'a mut IssueDeliveryState {
state
.issues
.entry(issue.identifier.clone())
.and_modify(|entry| {
entry.issue_id = issue.id.clone();
entry.title = issue.title.clone();
entry.url = issue.url.clone();
entry.project = issue.project.clone();
entry.epic = issue.epic.clone();
entry.updated_at = issue.updated_at.clone();
})
.or_insert_with(|| IssueDeliveryState {
issue_id: issue.id.clone(),
identifier: issue.identifier.clone(),
title: issue.title.clone(),
url: issue.url.clone(),
project: issue.project.clone(),
epic: issue.epic.clone(),
status: "queued".to_string(),
attempts: 0,
workspace: None,
workspace_strategy: None,
branch: None,
phases: vec![],
last_attempt_at: None,
next_retry_at: None,
last_error: None,
completed_at: None,
delivered_at: None,
review_ready_at: None,
ci_passed_at: None,
completion_gate: None,
completion_detail: None,
pr_url: None,
updated_at: issue.updated_at.clone(),
})
}
fn schedule_retry(issue_state: &mut IssueDeliveryState, max_attempts: usize, retry_base_ms: u64) {
if issue_state.attempts >= max_attempts {
issue_state.status = "failed".to_string();
issue_state.next_retry_at = None;
return;
}
let multiplier = 2u64.saturating_pow(issue_state.attempts.saturating_sub(1) as u32);
let next = Utc::now()
+ chrono::Duration::milliseconds((retry_base_ms.saturating_mul(multiplier)) as i64);
issue_state.status = "retry_wait".to_string();
issue_state.next_retry_at = Some(next.to_rfc3339_opts(SecondsFormat::Secs, true));
}
fn apply_failed_completion_state(
issue_state: &mut IssueDeliveryState,
completion: &CompletionResult,
max_attempts: usize,
retry_base_ms: u64,
) {
if matches!(completion.status.as_str(), "blocked" | "no_op") {
issue_state.status = completion.status.clone();
issue_state.next_retry_at = None;
issue_state.completed_at = Some(now_iso());
issue_state.completion_gate = Some(completion.gate.clone());
issue_state.completion_detail = Some(completion.detail.clone());
issue_state.pr_url = completion
.pr_url
.clone()
.or_else(|| issue_state.pr_url.clone());
return;
}
schedule_retry(issue_state, max_attempts, retry_base_ms);
}
fn is_terminal_status(status: &str, completion_gate: &str) -> bool {
if !TERMINAL_STATUSES.contains(&status) {
return false;
}
if matches!(status, "blocked" | "no_op") {
return true;
}
match completion_gate {
"ci-success" => status == "ci_passed",
"human-review" | "review-or-ci" => matches!(status, "review_ready" | "ci_passed"),
_ => true,
}
}
fn has_scoped_linear_target(resolved: &ResolvedArgs) -> bool {
!resolved.raw.linear_issue.is_empty()
|| !resolved.raw.linear_project.is_empty()
|| !resolved.raw.linear_epic.is_empty()
|| resolved.raw.linear_query.is_some()
|| resolved.raw.linear_team.is_some()
|| resolved.raw.linear_state.is_some()
|| resolved.raw.linear_assignee.is_some()
}
fn resolve_delivery_paths(resolved: &ResolvedArgs) -> DeliveryPaths {
let amon_hen_dir = resolved.cwd.join(".amon-hen");
let state_file = resolved
.raw
.linear_state_file
.as_ref()
.map(|path| absolutize(&resolved.cwd, path))
.unwrap_or_else(|| amon_hen_dir.join("linear-delivery-state.json"));
let workspace_root = resolved
.raw
.linear_workspace_root
.as_ref()
.map(|path| absolutize(&resolved.cwd, path))
.unwrap_or_else(|| amon_hen_dir.join("linear-workspaces"));
let observability_dir = resolved
.raw
.linear_observability_dir
.as_ref()
.map(|path| absolutize(&resolved.cwd, path))
.unwrap_or_else(|| amon_hen_dir.join("linear-observability"));
DeliveryPaths {
state_file,
workspace_root,
events_file: observability_dir.join("events.jsonl"),
observability_dir,
}
}
fn resolve_linear_authorization(
resolved: &ResolvedArgs,
allow_missing: bool,
) -> Result<Option<String>, String> {
let (env_name, oauth) = if resolved.raw.linear_auth == "oauth" {
(&resolved.raw.linear_oauth_token_env, true)
} else {
(&resolved.raw.linear_api_key_env, false)
};
let Some(value) = std::env::var(env_name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
if allow_missing {
return Ok(None);
}
return Err(if oauth {
format!("Linear delivery requires an OAuth token. Set {env_name} or pass --linear-oauth-token-env.")
} else {
format!(
"Linear delivery requires an API key. Set {env_name} or pass --linear-api-key-env."
)
});
};
Ok(Some(if oauth && !value.starts_with("Bearer ") {
format!("Bearer {value}")
} else {
value
}))
}
fn load_delivery_state(path: &Path) -> DeliveryState {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<DeliveryState>(&text).ok())
.unwrap_or_else(|| DeliveryState {
version: 1,
created_at: now_iso(),
updated_at: None,
issues: HashMap::new(),
})
}
fn write_delivery_state(path: &Path, state: &mut DeliveryState) -> Result<(), String> {
state.updated_at = Some(now_iso());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create state directory {}: {error}",
parent.display()
)
})?;
}
let text = serde_json::to_string_pretty(state)
.map_err(|error| format!("Failed to serialize Linear state: {error}"))?;
fs::write(path, format!("{text}\n"))
.map_err(|error| format!("Failed to write Linear state {}: {error}", path.display()))
}
fn ensure_delivery_dirs(paths: &DeliveryPaths) -> Result<(), String> {
if let Some(parent) = paths.state_file.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create state directory {}: {error}",
parent.display()
)
})?;
}
fs::create_dir_all(&paths.workspace_root).map_err(|error| {
format!(
"Failed to create workspace root {}: {error}",
paths.workspace_root.display()
)
})?;
fs::create_dir_all(&paths.observability_dir).map_err(|error| {
format!(
"Failed to create observability dir {}: {error}",
paths.observability_dir.display()
)
})
}
fn emit_event(
context: &DeliveryEventContext,
event_type: &str,
payload: Value,
) -> Result<(), String> {
let _guard = context
.event_lock
.lock()
.map_err(|_| "Linear observability lock was poisoned.".to_string())?;
let event = json!({
"type": event_type,
"at": now_iso(),
"payload": payload
});
let message = render_delivery_progress_event(event_type, &payload);
eprintln!("{message}");
let (stage, status) = delivery_progress_stage(event_type);
emit_runtime_event(
&context.progress,
false,
progress_event_with_context(
Some("linear"),
Some("delivery"),
stage,
status,
None,
None,
false,
None,
None,
vec![],
message,
),
);
if let Some(parent) = context.paths.events_file.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create observability dir {}: {error}",
parent.display()
)
})?;
}
let line = serde_json::to_string(&event)
.map_err(|error| format!("Failed to serialize delivery event: {error}"))?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&context.paths.events_file)
.map_err(|error| {
format!(
"Failed to open observability log {}: {error}",
context.paths.events_file.display()
)
})?;
writeln!(file, "{line}").map_err(|error| {
format!(
"Failed to write observability log {}: {error}",
context.paths.events_file.display()
)
})
}
fn delivery_progress_stage(event_type: &str) -> (ProgressStage, Option<&'static str>) {
match event_type {
"delivery_started" | "delivery_poll_started" | "delivery_phase_started" => {
(ProgressStage::Start, Some("running"))
}
"delivery_cancelled" => (ProgressStage::Done, Some("cancelled")),
"delivery_completed" | "delivery_target_completed" => (ProgressStage::Done, Some("ok")),
"delivery_max_polls_reached" => (ProgressStage::Done, Some("blocked")),
_ => (ProgressStage::Heartbeat, None),
}
}
fn render_delivery_progress_event(event_type: &str, payload: &Value) -> String {
match event_type {
"delivery_started" => "[delivery] linear started".to_string(),
"delivery_poll_started" => format!(
"[delivery] poll {} started",
payload.get("poll").and_then(Value::as_u64).unwrap_or(0)
),
"delivery_poll_completed" => format!(
"[delivery] poll {} completed fetched:{} dispatched:{} skipped:{}{}",
payload.get("poll").and_then(Value::as_u64).unwrap_or(0),
payload.get("fetched").and_then(Value::as_u64).unwrap_or(0),
payload
.get("dispatched")
.and_then(Value::as_u64)
.unwrap_or(0),
payload.get("skipped").and_then(Value::as_u64).unwrap_or(0),
if payload
.get("complete")
.and_then(Value::as_bool)
.unwrap_or(false)
{
" complete"
} else {
""
}
),
"delivery_issue_started" => "[delivery] issue started".to_string(),
"delivery_workspace_prepared" => "[delivery] workspace prepared".to_string(),
"delivery_phase_started" => format!(
"[delivery] phase {} started",
payload.get("phase").and_then(Value::as_str).unwrap_or("")
),
"delivery_phase_completed" => format!(
"[delivery] phase {} {}",
payload.get("phase").and_then(Value::as_str).unwrap_or(""),
if payload
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
"ok"
} else {
"failed"
}
),
"delivery_issue_completed" => "[delivery] issue completed".to_string(),
"delivery_target_completed" => "[delivery] target completed".to_string(),
"delivery_max_polls_reached" => format!(
"[delivery] max polls reached ({})",
payload.get("poll").and_then(Value::as_u64).unwrap_or(0)
),
"delivery_completed" => "[delivery] linear completed".to_string(),
_ => format!("[delivery] {event_type}"),
}
}
fn read_workflow_policy(resolved: &ResolvedArgs) -> String {
let path = resolved
.raw
.linear_workflow_file
.as_ref()
.map(|path| absolutize(&resolved.cwd, path))
.unwrap_or_else(|| resolved.cwd.join(DEFAULT_WORKFLOW_FILE));
fs::read_to_string(path)
.map(|text| text.trim().to_string())
.unwrap_or_default()
}
fn delivery_phases(resolved: &ResolvedArgs) -> Vec<String> {
if resolved.raw.delivery_phases.is_empty() {
DEFAULT_DELIVERY_PHASES
.iter()
.map(|phase| (*phase).to_string())
.collect()
} else {
normalize_values(&resolved.raw.delivery_phases)
}
}
fn build_delivery_phase_prompt(
phase: &str,
issue: &LinearIssue,
resolved: &ResolvedArgs,
conversation: &[(String, String)],
workflow_policy: &str,
) -> String {
let mut lines = vec![
format!("Linear task: {} - {}", issue.identifier, issue.title),
issue
.url
.as_ref()
.map(|url| format!("Linear URL: {url}"))
.unwrap_or_default(),
issue
.branch_name
.as_ref()
.map(|branch| format!("Suggested branch: {branch}"))
.unwrap_or_default(),
issue
.project
.as_ref()
.and_then(|project| project.name.as_ref())
.map(|name| format!("Linear project: {name}"))
.unwrap_or_default(),
issue
.epic
.as_ref()
.map(|epic| {
[epic.identifier.as_deref(), epic.title.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" - ")
})
.filter(|value| !value.is_empty())
.map(|value| format!("Linear epic: {value}"))
.unwrap_or_default(),
issue
.state
.as_ref()
.map(|state| format!("Current state: {state}"))
.unwrap_or_default(),
if issue.labels.is_empty() {
String::new()
} else {
format!("Labels: {}", issue.labels.join(", "))
},
issue
.assignee
.as_ref()
.map(|assignee| format!("Assignee: {assignee}"))
.unwrap_or_default(),
String::new(),
"Task description:".to_string(),
if issue.description.trim().is_empty() {
"(no description provided)".to_string()
} else {
issue.description.clone()
},
];
if !workflow_policy.trim().is_empty() {
lines.extend([
String::new(),
"Project workflow policy:".to_string(),
workflow_policy.to_string(),
]);
}
if !resolved.prompt.trim().is_empty() {
lines.extend([
String::new(),
format!("Operator guidance: {}", resolved.prompt.trim()),
]);
}
if !conversation.is_empty() {
lines.push(String::new());
lines.push("Previous delivery phase handoff:".to_string());
for (name, output) in conversation {
lines.push(format!("### {name}"));
lines.push(output.trim().to_string());
}
}
lines.extend([
String::new(),
format!("Amon Hen phase: {phase}."),
format!(
"Completion gate: {}.",
normalize_completion_gate(&resolved.raw.linear_completion_gate)
),
]);
if let Some(review_state) = &resolved.raw.linear_review_state {
lines.push(format!("Human-review Linear state: {review_state}."));
}
let instruction = match phase {
"plan" => "Create a concrete delivery plan. Identify files to inspect, implementation steps, tests, GitHub/PR requirements, and risks. Do not make code changes unless absolutely necessary.",
"implement" => "Implement the task in this isolated issue workspace. Use the prior plan and handoff context. Keep the change scoped to the Linear issue. Preserve user changes.",
"verify" => "Verify the implementation in this isolated issue workspace. Run relevant tests, builds, linters, or targeted commands. Fix in-scope failures and rerun the relevant checks.",
_ => "Ship the work from this isolated issue workspace. Inspect git status and diff, scan for secrets, commit with the issue context, push a branch, open or update the GitHub PR, and include tests run and residual risks.",
};
lines.extend([String::new(), instruction.to_string()]);
lines
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn pick_phase_planner(phase: &str, members: &[String], requested: Option<&str>) -> Option<String> {
if let Some(requested) = requested.filter(|value| members.iter().any(|member| member == value))
{
return Some(requested.to_string());
}
if phase == "plan" {
return members.first().cloned();
}
if members.iter().any(|member| member == "codex") {
Some("codex".to_string())
} else {
members.first().cloned()
}
}
fn pick_phase_lead(phase: &str, members: &[String], requested: Option<&str>) -> Option<String> {
if let Some(requested) = requested.filter(|value| members.iter().any(|member| member == value))
{
return Some(requested.to_string());
}
if phase == "verify" && members.iter().any(|member| member == "gemini") {
Some("gemini".to_string())
} else if members.iter().any(|member| member == "codex") {
Some("codex".to_string())
} else {
members.first().cloned()
}
}
fn effective_max_attempts(resolved: &ResolvedArgs) -> usize {
if resolved.raw.linear_until_complete {
usize::MAX
} else if resolved.raw.linear_max_attempts == 0 {
DEFAULT_MAX_ATTEMPTS
} else {
resolved.raw.linear_max_attempts
}
}
fn effective_linear_watch(resolved: &ResolvedArgs) -> bool {
resolved.raw.linear_watch || resolved.raw.linear_until_complete
}
fn normalize_completion_gate(value: &str) -> String {
match value {
"delivered" | "human-review" | "ci-success" | "review-or-ci" => value.to_string(),
_ => "delivered".to_string(),
}
}
fn summarize_delivery_state(state: &DeliveryState) -> DeliveryCounts {
let mut counts = DeliveryCounts::default();
for issue in state.issues.values() {
counts.total += 1;
match issue.status.as_str() {
"delivered" => counts.delivered += 1,
"review_ready" => counts.review_ready += 1,
"ci_passed" => counts.ci_passed += 1,
"blocked" => counts.blocked += 1,
"no_op" => counts.no_op += 1,
"running" => counts.running += 1,
"retry_wait" => counts.retry_wait += 1,
"failed" => counts.failed += 1,
"ineligible" => counts.ineligible += 1,
_ => {}
}
}
counts
}
fn normalize_values(values: &[String]) -> Vec<String> {
values
.iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect()
}
fn absolutize(cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn safe_workspace_name(value: &str) -> String {
let mut name = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if name.is_empty() {
name = "issue".to_string();
}
name
}
fn linear_endpoint(resolved: &ResolvedArgs) -> String {
resolved
.raw
.linear_endpoint
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("LINEAR_GRAPHQL_ENDPOINT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| DEFAULT_LINEAR_ENDPOINT.to_string())
}
fn now_iso() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn same_text(left: &str, right: &str) -> bool {
left.trim().eq_ignore_ascii_case(right.trim())
}
fn media_title(value: &str) -> String {
if value.starts_with("http://") || value.starts_with("https://") {
value
.rsplit('/')
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or(value)
.to_string()
} else {
Path::new(value)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(value)
.to_string()
}
}
fn infer_content_type(filename: &str) -> &'static str {
match Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"avif" => "image/avif",
"bmp" => "image/bmp",
"gif" => "image/gif",
"jpeg" | "jpg" => "image/jpeg",
"mov" => "video/quicktime",
"mp4" => "video/mp4",
"pdf" => "application/pdf",
"png" => "image/png",
"svg" => "image/svg+xml",
"txt" => "text/plain",
"webm" => "video/webm",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_workspace_names() {
assert_eq!(
safe_workspace_name("ENG-123 Add OAuth!"),
"eng-123-add-oauth"
);
assert_eq!(safe_workspace_name("!!!"), "issue");
}
#[test]
fn extracts_github_pr_urls() {
assert_eq!(
extract_github_pr_url("Ready: https://github.com/Dviros/Amon-Hen/pull/7."),
Some("https://github.com/Dviros/Amon-Hen/pull/7".to_string())
);
}
#[test]
fn infers_common_media_content_types() {
assert_eq!(infer_content_type("screen.png"), "image/png");
assert_eq!(infer_content_type("notes.txt"), "text/plain");
assert_eq!(
infer_content_type("archive.bin"),
"application/octet-stream"
);
}
#[test]
fn delivery_harness_marks_unchanged_workspace_no_op() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("README.md"), "hello\n").unwrap();
let baseline = capture_workspace_snapshot(dir.path());
let issue = LinearIssue {
id: "issue-id".to_string(),
identifier: "ENG-1".to_string(),
title: "No-op".to_string(),
..LinearIssue::default()
};
let issue_state = IssueDeliveryState {
issue_id: issue.id.clone(),
identifier: issue.identifier.clone(),
title: issue.title.clone(),
workspace: Some(dir.path().display().to_string()),
..IssueDeliveryState::default()
};
let harness =
evaluate_delivery_harness(dir.path(), &baseline, &issue, &issue_state, &[], None);
assert!(!harness.repo_changed);
assert!(harness.no_op_detail.is_some());
assert!(harness.blocked_detail.is_none());
assert!(harness
.gates
.iter()
.any(|gate| gate.name == "change-detection" && gate.status == "no_op"));
}
#[test]
fn secret_and_local_path_scan_blocks_delivery() {
let dir = tempfile::tempdir().unwrap();
let local_path = ["/", "Users", "alice", "private"].join("/");
fs::write(
dir.path().join("notes.txt"),
format!("temporary output from {local_path}\n"),
)
.unwrap();
let gates = scan_workspace_for_sensitive_evidence(dir.path());
assert!(gates.iter().any(|gate| {
gate.name == "secret-local-path-scan"
&& gate.status == "blocked"
&& gate.detail.contains("local absolute path")
}));
}
#[test]
fn failed_completion_state_does_not_retry_blocked_or_no_op() {
let mut blocked_state = IssueDeliveryState {
attempts: 1,
status: "running".to_string(),
..IssueDeliveryState::default()
};
let blocked = test_completion("blocked", "secret scan blocked delivery");
apply_failed_completion_state(&mut blocked_state, &blocked, 3, 1_000);
assert_eq!(blocked_state.status, "blocked");
assert!(blocked_state.next_retry_at.is_none());
assert!(blocked_state.completed_at.is_some());
let mut retry_state = IssueDeliveryState {
attempts: 1,
status: "running".to_string(),
..IssueDeliveryState::default()
};
let retry = test_completion("retry_wait", "GitHub checks are pending");
apply_failed_completion_state(&mut retry_state, &retry, 3, 1_000);
assert_eq!(retry_state.status, "retry_wait");
assert!(retry_state.next_retry_at.is_some());
}
#[test]
fn github_check_failures_are_reported_as_blocked() {
let result = CommandResult {
command: "gh".to_string(),
args: vec!["pr".to_string(), "checks".to_string()],
code: Some(1),
stdout: r#"[{"name":"test","bucket":"fail"}]"#.to_string(),
stderr: String::new(),
timed_out: false,
cancelled: false,
error: None,
timeout_ms: 1_000,
duration_ms: 7,
};
let completion =
interpret_github_checks(&result, "https://github.com/acme/repo/pull/5", "ci-success");
assert!(!completion.success);
assert_eq!(completion.status, "blocked");
assert!(completion.detail.contains("test"));
assert!(completion.gates.iter().any(|gate| gate.name == "github-ci"));
}
fn test_completion(status: &str, detail: &str) -> CompletionResult {
CompletionResult {
success: false,
status: status.to_string(),
gate: "delivered".to_string(),
detail: detail.to_string(),
pr_url: None,
gates: vec![],
repo_changed: false,
}
}
}