Skip to main content

tandem_server/
bug_monitor_github.rs

1use anyhow::Context;
2use serde_json::{json, Value};
3use std::collections::BTreeSet;
4use std::time::Duration;
5use tandem_runtime::mcp_ready::{EnsureReadyPolicy, McpReadyError};
6use tandem_runtime::McpRemoteTool;
7use tandem_types::EngineEvent;
8
9use crate::bug_monitor::error_provenance::{locate_error_provenance, render_provenance_section};
10use crate::http::context_runs::context_run_events_path;
11use crate::{
12    now_ms, sha256_hex, truncate_text, AppState, BugMonitorConfig, BugMonitorDraftRecord,
13    BugMonitorIncidentRecord, BugMonitorPostRecord, ExternalActionRecord,
14};
15use std::fs;
16use std::path::Path;
17
18use tandem_core::ToolEffectLedgerRecord;
19
20const BUG_MONITOR_LABEL: &str = "bug-monitor";
21const ISSUE_BODY_MARKER_SAFE_SPACE: usize = 2;
22const ISSUE_BODY_BYTE_BUDGET: usize = 12_000;
23const ISSUE_BODY_LOG_CHAR_BUDGET: usize = 4_000;
24const ISSUE_BODY_LOG_LINES: usize = 30;
25const ISSUE_BODY_LOG_FALLBACK_LINES: usize = 12;
26const ISSUE_BODY_EVIDENCE_REF_LIMIT: usize = 15;
27const ISSUE_BODY_QUALITY_GATE_MISSING_LIMIT: usize = 20;
28const ISSUE_BODY_TRIAGE_TIMEOUT_DETAIL_LINES: usize = 20;
29const ISSUE_BODY_TOOL_EVIDENCE_LIMIT: usize = 12;
30const ISSUE_BODY_TOOL_ERROR_CHAR_BUDGET: usize = 200;
31const ISSUE_BODY_TOOL_RESULT_CHAR_BUDGET: usize = 220;
32const ISSUE_BODY_TOOL_RECORD_BUDGET: usize = 640;
33
34// Evidence policy:
35// Keep GitHub issue bodies bounded and human-readable by preserving the
36// strongest snippets inline and pushing deep context into artifacts/events.
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PublishMode {
40    Auto,
41    Recovery,
42    ManualPublish,
43    RecheckOnly,
44}
45
46#[derive(Debug, Clone)]
47pub struct PublishOutcome {
48    pub action: String,
49    pub draft: BugMonitorDraftRecord,
50    pub post: Option<BugMonitorPostRecord>,
51}
52
53pub async fn record_post_failure(
54    state: &AppState,
55    draft: &BugMonitorDraftRecord,
56    incident_id: Option<&str>,
57    operation: &str,
58    evidence_digest: Option<&str>,
59    error: &str,
60) -> anyhow::Result<BugMonitorPostRecord> {
61    let now = now_ms();
62    let post = BugMonitorPostRecord {
63        post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
64        draft_id: draft.draft_id.clone(),
65        incident_id: incident_id.map(|value| value.to_string()),
66        fingerprint: draft.fingerprint.clone(),
67        repo: draft.repo.clone(),
68        operation: operation.to_string(),
69        status: "failed".to_string(),
70        issue_number: draft.issue_number,
71        issue_url: draft.github_issue_url.clone(),
72        comment_id: None,
73        comment_url: draft.github_comment_url.clone(),
74        evidence_digest: evidence_digest.map(|value| value.to_string()),
75        confidence: draft.confidence.clone(),
76        risk_level: draft.risk_level.clone(),
77        expected_destination: draft.expected_destination.clone(),
78        evidence_refs: draft.evidence_refs.clone(),
79        quality_gate: draft.quality_gate.clone(),
80        idempotency_key: build_idempotency_key(
81            &draft.repo,
82            &draft.fingerprint,
83            operation,
84            evidence_digest.unwrap_or(""),
85        ),
86        response_excerpt: None,
87        error: Some(truncate_text(error, 500)),
88        created_at_ms: now,
89        updated_at_ms: now,
90    };
91    let post = state.put_bug_monitor_post(post).await?;
92    mirror_bug_monitor_post_as_external_action(state, draft, &post).await;
93    Ok(post)
94}
95
96async fn mirror_bug_monitor_post_as_external_action(
97    state: &AppState,
98    draft: &BugMonitorDraftRecord,
99    post: &BugMonitorPostRecord,
100) {
101    let capability_id = match post.operation.as_str() {
102        "comment_issue" => Some("github.comment_on_issue".to_string()),
103        "create_issue" => Some("github.create_issue".to_string()),
104        _ => None,
105    };
106    let action = ExternalActionRecord {
107        action_id: post.post_id.clone(),
108        operation: post.operation.clone(),
109        status: post.status.clone(),
110        source_kind: Some("bug_monitor".to_string()),
111        source_id: Some(draft.draft_id.clone()),
112        routine_run_id: None,
113        context_run_id: draft.triage_run_id.clone(),
114        capability_id,
115        provider: Some(BUG_MONITOR_LABEL.to_string()),
116        target: Some(draft.repo.clone()),
117        approval_state: Some(if draft.status.eq_ignore_ascii_case("approval_required") {
118            "approval_required".to_string()
119        } else {
120            "executed".to_string()
121        }),
122        idempotency_key: Some(post.idempotency_key.clone()),
123        receipt: Some(json!({
124            "post_id": post.post_id,
125            "draft_id": post.draft_id,
126            "incident_id": post.incident_id,
127            "issue_number": post.issue_number,
128            "issue_url": post.issue_url,
129            "comment_id": post.comment_id,
130            "comment_url": post.comment_url,
131            "response_excerpt": post.response_excerpt,
132        })),
133        error: post.error.clone(),
134        metadata: Some(json!({
135            "repo": post.repo,
136            "fingerprint": post.fingerprint,
137            "evidence_digest": post.evidence_digest,
138            "confidence": post.confidence,
139            "risk_level": post.risk_level,
140            "expected_destination": post.expected_destination,
141            "evidence_refs": post.evidence_refs,
142            "quality_gate": post.quality_gate,
143            "bug_monitor_operation": post.operation,
144        })),
145        created_at_ms: post.created_at_ms,
146        updated_at_ms: post.updated_at_ms,
147    };
148    if let Err(error) = state.record_external_action(action).await {
149        tracing::warn!(
150            "failed to persist external action mirror for bug monitor post {}: {}",
151            post.post_id,
152            error
153        );
154    }
155}
156
157#[derive(Debug, Clone, Default)]
158struct GithubToolSet {
159    server_name: String,
160    list_issues: String,
161    get_issue: String,
162    create_issue: String,
163    comment_on_issue: String,
164}
165
166#[derive(Debug, Clone, Default)]
167struct GithubIssue {
168    number: u64,
169    title: String,
170    body: String,
171    state: String,
172    html_url: Option<String>,
173}
174
175#[derive(Debug, Clone, Default)]
176struct GithubComment {
177    id: Option<String>,
178    html_url: Option<String>,
179}
180
181pub async fn publish_draft(
182    state: &AppState,
183    draft_id: &str,
184    incident_id: Option<&str>,
185    mode: PublishMode,
186) -> anyhow::Result<PublishOutcome> {
187    let status = state.bug_monitor_status_snapshot().await;
188    let config = status.config.clone();
189    if !config.enabled {
190        anyhow::bail!("Bug Monitor is disabled");
191    }
192    if config.paused && matches!(mode, PublishMode::Auto | PublishMode::Recovery) {
193        anyhow::bail!("Bug Monitor is paused");
194    }
195    if !status.readiness.publish_ready && mode == PublishMode::Auto {
196        anyhow::bail!("{}", bug_monitor_publish_not_ready_reason(&status));
197    }
198    let mut draft = state
199        .get_bug_monitor_draft(draft_id)
200        .await
201        .ok_or_else(|| anyhow::anyhow!("Bug Monitor draft not found"))?;
202    if draft.status.eq_ignore_ascii_case("denied") {
203        anyhow::bail!("Bug Monitor draft has been denied");
204    }
205    if mode == PublishMode::Auto
206        && config.require_approval_for_new_issues
207        && draft.status.eq_ignore_ascii_case("approval_required")
208    {
209        return Ok(PublishOutcome {
210            action: "approval_required".to_string(),
211            draft,
212            post: None,
213        });
214    }
215
216    let tools = resolve_github_tool_set(state, &config)
217        .await
218        .context("resolve GitHub MCP tools for Bug Monitor")?;
219    let incident = match incident_id {
220        Some(id) => state.get_bug_monitor_incident(id).await,
221        None => None,
222    };
223    let evidence_digest = compute_evidence_digest(&draft, incident.as_ref());
224    draft.evidence_digest = Some(evidence_digest.clone());
225    if mode != PublishMode::RecheckOnly {
226        if let Some(existing) =
227            successful_post_for_draft(state, &draft.draft_id, Some(&evidence_digest)).await
228        {
229            draft.github_status = Some("duplicate_skipped".to_string());
230            draft.issue_number = existing.issue_number;
231            draft.github_issue_url = existing.issue_url.clone();
232            draft.github_comment_url = existing.comment_url.clone();
233            draft.github_posted_at_ms = Some(existing.updated_at_ms);
234            draft.last_post_error = None;
235            mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
236            let draft = state.put_bug_monitor_draft(draft).await?;
237            return Ok(PublishOutcome {
238                action: "skip_duplicate".to_string(),
239                draft,
240                post: Some(existing),
241            });
242        }
243    }
244    let issue_draft = if mode == PublishMode::RecheckOnly {
245        None
246    } else if draft.triage_run_id.is_none() {
247        if mode == PublishMode::ManualPublish {
248            anyhow::bail!("Bug Monitor draft needs a triage run before GitHub publish");
249        }
250        None
251    } else if mode == PublishMode::ManualPublish {
252        Some(
253            crate::http::bug_monitor::ensure_bug_monitor_issue_draft(
254                state.clone(),
255                &draft.draft_id,
256                false,
257            )
258            .await
259            .context("generate Bug Monitor issue draft")?,
260        )
261    } else {
262        crate::http::bug_monitor::load_bug_monitor_issue_draft_artifact(
263            state,
264            draft.triage_run_id.as_deref().unwrap_or_default(),
265        )
266        .await
267    };
268    let triage_marked_timed_out = draft
269        .github_status
270        .as_deref()
271        .is_some_and(|status| status.eq_ignore_ascii_case("triage_timed_out"));
272    if issue_draft.is_none()
273        && draft.triage_run_id.is_some()
274        && !triage_marked_timed_out
275        && mode == PublishMode::Auto
276    {
277        draft.github_status = Some("triage_pending".to_string());
278        let draft = state.put_bug_monitor_draft(draft).await?;
279        return Ok(PublishOutcome {
280            action: "triage_pending".to_string(),
281            draft,
282            post: None,
283        });
284    }
285
286    let owner_repo = split_owner_repo(&draft.repo)?;
287    let matched_issue = find_matching_issue(state, &tools, &owner_repo, &draft)
288        .await
289        .context("match existing GitHub issue for Bug Monitor draft")?;
290
291    match matched_issue {
292        Some(issue) if issue.state.eq_ignore_ascii_case("open") => {
293            draft.matched_issue_number = Some(issue.number);
294            draft.matched_issue_state = Some(issue.state.clone());
295            if mode == PublishMode::RecheckOnly {
296                let draft = state.put_bug_monitor_draft(draft).await?;
297                return Ok(PublishOutcome {
298                    action: "matched_open".to_string(),
299                    draft,
300                    post: None,
301                });
302            }
303            if !config.auto_comment_on_matched_open_issues {
304                draft.github_status = Some("draft_ready".to_string());
305                let draft = state.put_bug_monitor_draft(draft).await?;
306                return Ok(PublishOutcome {
307                    action: "matched_open_no_comment".to_string(),
308                    draft,
309                    post: None,
310                });
311            }
312            let idempotency_key = build_idempotency_key(
313                &draft.repo,
314                &draft.fingerprint,
315                "comment_issue",
316                &evidence_digest,
317            );
318            if let Some(existing) = successful_post_by_idempotency(state, &idempotency_key).await {
319                draft.github_status = Some("duplicate_skipped".to_string());
320                draft.issue_number = existing.issue_number;
321                draft.github_issue_url = existing.issue_url.clone();
322                draft.github_comment_url = existing.comment_url.clone();
323                draft.github_posted_at_ms = Some(existing.updated_at_ms);
324                draft.last_post_error = None;
325                mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
326                let draft = state.put_bug_monitor_draft(draft).await?;
327                return Ok(PublishOutcome {
328                    action: "skip_duplicate".to_string(),
329                    draft,
330                    post: Some(existing),
331                });
332            }
333            let body = build_comment_body(
334                &draft,
335                incident.as_ref(),
336                issue.number,
337                &evidence_digest,
338                issue_draft.as_ref(),
339            );
340            let body =
341                append_error_provenance_section(state, body, &draft, incident.as_ref()).await;
342            let result = call_add_issue_comment(state, &tools, &owner_repo, issue.number, &body)
343                .await
344                .context("post Bug Monitor comment to GitHub")?;
345            let post = BugMonitorPostRecord {
346                post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
347                draft_id: draft.draft_id.clone(),
348                incident_id: incident.as_ref().map(|row| row.incident_id.clone()),
349                fingerprint: draft.fingerprint.clone(),
350                repo: draft.repo.clone(),
351                operation: "comment_issue".to_string(),
352                status: "posted".to_string(),
353                issue_number: Some(issue.number),
354                issue_url: issue.html_url.clone(),
355                comment_id: result.id.clone(),
356                comment_url: result.html_url.clone(),
357                evidence_digest: Some(evidence_digest.clone()),
358                confidence: draft.confidence.clone(),
359                risk_level: draft.risk_level.clone(),
360                expected_destination: draft.expected_destination.clone(),
361                evidence_refs: draft.evidence_refs.clone(),
362                quality_gate: draft.quality_gate.clone(),
363                idempotency_key,
364                response_excerpt: Some(truncate_text(&body, 400)),
365                error: None,
366                created_at_ms: now_ms(),
367                updated_at_ms: now_ms(),
368            };
369            let post = state.put_bug_monitor_post(post).await?;
370            mirror_bug_monitor_post_as_external_action(state, &draft, &post).await;
371            draft.status = "github_comment_posted".to_string();
372            draft.github_status = Some("github_comment_posted".to_string());
373            draft.github_issue_url = issue.html_url.clone();
374            draft.github_comment_url = result.html_url.clone();
375            draft.github_posted_at_ms = Some(post.updated_at_ms);
376            draft.issue_number = Some(issue.number);
377            draft.last_post_error = None;
378            let draft = state.put_bug_monitor_draft(draft).await?;
379            state
380                .update_bug_monitor_runtime_status(|runtime| {
381                    runtime.last_post_result = Some(format!("commented issue #{}", issue.number));
382                })
383                .await;
384            state.event_bus.publish(EngineEvent::new(
385                "bug_monitor.github.comment_posted",
386                json!({
387                    "draft_id": draft.draft_id,
388                    "issue_number": issue.number,
389                    "repo": draft.repo,
390                }),
391            ));
392            Ok(PublishOutcome {
393                action: "comment_issue".to_string(),
394                draft,
395                post: Some(post),
396            })
397        }
398        Some(issue) => {
399            draft.matched_issue_number = Some(issue.number);
400            draft.matched_issue_state = Some(issue.state.clone());
401            if mode == PublishMode::RecheckOnly {
402                let draft = state.put_bug_monitor_draft(draft).await?;
403                return Ok(PublishOutcome {
404                    action: "matched_closed".to_string(),
405                    draft,
406                    post: None,
407                });
408            }
409            create_issue_from_draft(
410                state,
411                &tools,
412                &config,
413                draft,
414                incident.as_ref(),
415                Some(&issue),
416                &evidence_digest,
417                issue_draft.as_ref(),
418            )
419            .await
420        }
421        None => {
422            if mode == PublishMode::RecheckOnly {
423                let draft = state.put_bug_monitor_draft(draft).await?;
424                return Ok(PublishOutcome {
425                    action: "no_match".to_string(),
426                    draft,
427                    post: None,
428                });
429            }
430            create_issue_from_draft(
431                state,
432                &tools,
433                &config,
434                draft,
435                incident.as_ref(),
436                None,
437                &evidence_digest,
438                issue_draft.as_ref(),
439            )
440            .await
441        }
442    }
443}
444
445fn bug_monitor_publish_not_ready_reason(status: &crate::BugMonitorStatus) -> String {
446    if let Some(error) = status.last_error.as_ref() {
447        let model_only_not_ready = !status.readiness.selected_model_ready
448            && status.readiness.repo_valid
449            && status.readiness.mcp_connected
450            && status.readiness.github_read_ready
451            && status.readiness.github_write_ready;
452        if !model_only_not_ready {
453            return error.clone();
454        }
455    }
456    "Bug Monitor is not ready for GitHub posting".to_string()
457}
458
459async fn create_issue_from_draft(
460    state: &AppState,
461    tools: &GithubToolSet,
462    config: &BugMonitorConfig,
463    mut draft: BugMonitorDraftRecord,
464    incident: Option<&crate::BugMonitorIncidentRecord>,
465    matched_closed_issue: Option<&GithubIssue>,
466    evidence_digest: &str,
467    issue_draft: Option<&Value>,
468) -> anyhow::Result<PublishOutcome> {
469    if config.require_approval_for_new_issues && !draft.status.eq_ignore_ascii_case("draft_ready") {
470        draft.status = "approval_required".to_string();
471        draft.github_status = Some("approval_required".to_string());
472        let draft = state.put_bug_monitor_draft(draft).await?;
473        return Ok(PublishOutcome {
474            action: "approval_required".to_string(),
475            draft,
476            post: None,
477        });
478    }
479    if !config.auto_create_new_issues && draft.status.eq_ignore_ascii_case("draft_ready") {
480        let draft = state.put_bug_monitor_draft(draft).await?;
481        return Ok(PublishOutcome {
482            action: "draft_ready".to_string(),
483            draft,
484            post: None,
485        });
486    }
487    let idempotency_key = build_idempotency_key(
488        &draft.repo,
489        &draft.fingerprint,
490        "create_issue",
491        evidence_digest,
492    );
493    if let Some(existing) = successful_post_by_idempotency(state, &idempotency_key).await {
494        draft.status = "github_issue_created".to_string();
495        draft.github_status = Some("github_issue_created".to_string());
496        draft.issue_number = existing.issue_number;
497        draft.github_issue_url = existing.issue_url.clone();
498        draft.github_posted_at_ms = Some(existing.updated_at_ms);
499        draft.last_post_error = None;
500        mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
501        let draft = state.put_bug_monitor_draft(draft).await?;
502        return Ok(PublishOutcome {
503            action: "skip_duplicate".to_string(),
504            draft,
505            post: Some(existing),
506        });
507    }
508    if let Some(previous) = latest_failed_create_post_for_draft(state, &draft).await {
509        let detail = format!(
510            "suppressed automatic GitHub issue creation for fingerprint {} after previous {} post attempt {} failed; refusing to retry create_issue because the previous attempt may have created an issue without returning a parseable payload",
511            draft.fingerprint, previous.operation, previous.post_id
512        );
513        draft.status = "github_post_failed".to_string();
514        draft.github_status = Some("github_post_failed".to_string());
515        draft.last_post_error = Some(truncate_text(&detail, 500));
516        let draft = state.put_bug_monitor_draft(draft).await?;
517        return Ok(PublishOutcome {
518            action: "create_issue_retry_suppressed".to_string(),
519            draft,
520            post: Some(previous),
521        });
522    }
523
524    let owner_repo = split_owner_repo(&draft.repo)?;
525    let title = issue_draft
526        .and_then(|row| row.get("suggested_title"))
527        .and_then(Value::as_str)
528        .filter(|value| !value.trim().is_empty())
529        .unwrap_or_else(|| draft.title.as_deref().unwrap_or("Bug Monitor issue"));
530    let body = issue_draft
531        .and_then(|row| row.get("rendered_body"))
532        .and_then(Value::as_str)
533        .filter(|value| !value.trim().is_empty())
534        .map(ToString::to_string)
535        .unwrap_or_else(|| {
536            build_issue_body(&draft, incident, matched_closed_issue, evidence_digest)
537        });
538    let body = append_error_provenance_section(state, body, &draft, incident).await;
539    let created = match call_create_issue(state, tools, &owner_repo, title, &body).await {
540        Ok(created) => created,
541        Err(error) => {
542            if let Err(record_err) = record_post_failure(
543                state,
544                &draft,
545                incident.map(|row| row.incident_id.as_str()),
546                "create_issue",
547                Some(evidence_digest),
548                &error.to_string(),
549            )
550            .await
551            {
552                tracing::warn!(
553                    draft_id = %draft.draft_id,
554                    error = %record_err,
555                    "failed to record ambiguous Bug Monitor create_issue failure",
556                );
557            }
558            return Err(error).context("create Bug Monitor issue on GitHub");
559        }
560    };
561    let post = BugMonitorPostRecord {
562        post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
563        draft_id: draft.draft_id.clone(),
564        incident_id: incident.map(|row| row.incident_id.clone()),
565        fingerprint: draft.fingerprint.clone(),
566        repo: draft.repo.clone(),
567        operation: "create_issue".to_string(),
568        status: "posted".to_string(),
569        issue_number: Some(created.number),
570        issue_url: created.html_url.clone(),
571        comment_id: None,
572        comment_url: None,
573        evidence_digest: Some(evidence_digest.to_string()),
574        confidence: draft.confidence.clone(),
575        risk_level: draft.risk_level.clone(),
576        expected_destination: draft.expected_destination.clone(),
577        evidence_refs: draft.evidence_refs.clone(),
578        quality_gate: draft.quality_gate.clone(),
579        idempotency_key,
580        response_excerpt: Some(truncate_text(&body, 400)),
581        error: None,
582        created_at_ms: now_ms(),
583        updated_at_ms: now_ms(),
584    };
585    let post = state.put_bug_monitor_post(post).await?;
586    mirror_bug_monitor_post_as_external_action(state, &draft, &post).await;
587    draft.status = "github_issue_created".to_string();
588    draft.github_status = Some("github_issue_created".to_string());
589    draft.github_issue_url = created.html_url.clone();
590    draft.github_posted_at_ms = Some(post.updated_at_ms);
591    draft.issue_number = Some(created.number);
592    draft.last_post_error = None;
593    let draft = state.put_bug_monitor_draft(draft).await?;
594    state
595        .update_bug_monitor_runtime_status(|runtime| {
596            runtime.last_post_result = Some(format!("created issue #{}", created.number));
597        })
598        .await;
599    state.event_bus.publish(EngineEvent::new(
600        "bug_monitor.github.issue_created",
601        json!({
602            "draft_id": draft.draft_id,
603            "issue_number": created.number,
604            "repo": draft.repo,
605        }),
606    ));
607    Ok(PublishOutcome {
608        action: "create_issue".to_string(),
609        draft,
610        post: Some(post),
611    })
612}
613
614async fn resolve_github_tool_set(
615    state: &AppState,
616    config: &BugMonitorConfig,
617) -> anyhow::Result<GithubToolSet> {
618    let server_name = config
619        .mcp_server
620        .as_ref()
621        .filter(|value| !value.trim().is_empty())
622        .ok_or_else(|| anyhow::anyhow!("Bug Monitor MCP server is not configured"))?
623        .to_string();
624    state
625        .mcp
626        .ensure_ready(&server_name, EnsureReadyPolicy::with_retries(3, 750))
627        .await
628        .map_err(|error| match error {
629            McpReadyError::NotFound => {
630                anyhow::anyhow!("Bug Monitor MCP server `{server_name}` was not found")
631            }
632            McpReadyError::Disabled => {
633                anyhow::anyhow!("Bug Monitor MCP server `{server_name}` is disabled")
634            }
635            McpReadyError::PermanentlyFailed { last_error } => {
636                let detail = last_error.unwrap_or_else(|| "connect failed".to_string());
637                anyhow::anyhow!(
638                    "Bug Monitor MCP server `{server_name}` was not ready for GitHub publish: {detail}"
639                )
640            }
641        })?;
642    let server_tools = state.mcp.server_tools(&server_name).await;
643    if server_tools.is_empty() {
644        anyhow::bail!("no MCP tools were discovered for selected Bug Monitor server");
645    }
646    let discovered = state
647        .capability_resolver
648        .discover_from_runtime(server_tools.clone(), Vec::new())
649        .await;
650    let mut resolved = state
651        .capability_resolver
652        .resolve(
653            crate::capability_resolver::CapabilityResolveInput {
654                workflow_id: Some("bug-monitor-github".to_string()),
655                required_capabilities: vec![
656                    "github.list_issues".to_string(),
657                    "github.get_issue".to_string(),
658                    "github.create_issue".to_string(),
659                    "github.comment_on_issue".to_string(),
660                ],
661                optional_capabilities: Vec::new(),
662                provider_preference: vec!["mcp".to_string()],
663                available_tools: discovered,
664            },
665            Vec::new(),
666        )
667        .await?;
668    if !resolved.missing_required.is_empty() {
669        let _ = state.capability_resolver.refresh_builtin_bindings().await;
670        let discovered = state
671            .capability_resolver
672            .discover_from_runtime(server_tools.clone(), Vec::new())
673            .await;
674        resolved = state
675            .capability_resolver
676            .resolve(
677                crate::capability_resolver::CapabilityResolveInput {
678                    workflow_id: Some("bug-monitor-github".to_string()),
679                    required_capabilities: vec![
680                        "github.list_issues".to_string(),
681                        "github.get_issue".to_string(),
682                        "github.create_issue".to_string(),
683                        "github.comment_on_issue".to_string(),
684                    ],
685                    optional_capabilities: Vec::new(),
686                    provider_preference: vec!["mcp".to_string()],
687                    available_tools: discovered,
688                },
689                Vec::new(),
690            )
691            .await?;
692    }
693    let tool_name = |capability_id: &str| -> anyhow::Result<String> {
694        let namespaced = resolved
695            .resolved
696            .iter()
697            .find(|row| row.capability_id == capability_id)
698            .map(|row| row.tool_name.clone())
699            .ok_or_else(|| anyhow::anyhow!("missing resolved tool for {capability_id}"))?;
700        map_namespaced_to_raw_tool(&server_tools, &namespaced)
701    };
702    let direct_tool_name_fallback = |candidates: &[&str]| -> Option<String> {
703        server_tools
704            .iter()
705            .find(|row| {
706                candidates.iter().any(|candidate| {
707                    row.tool_name.eq_ignore_ascii_case(candidate)
708                        || row.namespaced_name.eq_ignore_ascii_case(candidate)
709                })
710            })
711            .map(|row| row.tool_name.clone())
712    };
713    let list_issues = tool_name("github.list_issues").or_else(|_| {
714        direct_tool_name_fallback(&[
715            "list_issues",
716            "list_repository_issues",
717            "mcp.github.list_issues",
718            "mcp.githubcopilot.list_issues",
719        ])
720        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.list_issues"))
721    })?;
722    let get_issue = tool_name("github.get_issue").or_else(|_| {
723        direct_tool_name_fallback(&[
724            "get_issue",
725            "issue_read",
726            "mcp.github.get_issue",
727            "mcp.github.issue_read",
728            "mcp.githubcopilot.issue_read",
729        ])
730        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.get_issue"))
731    })?;
732    let create_issue = tool_name("github.create_issue").or_else(|_| {
733        direct_tool_name_fallback(&[
734            "create_issue",
735            "issue_write",
736            "mcp.github.create_issue",
737            "mcp.github.issue_write",
738            "mcp.githubcopilot.issue_write",
739        ])
740        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.create_issue"))
741    })?;
742    let comment_on_issue = tool_name("github.comment_on_issue").or_else(|_| {
743        direct_tool_name_fallback(&[
744            "add_issue_comment",
745            "create_issue_comment",
746            "mcp.github.add_issue_comment",
747            "mcp.github.create_issue_comment",
748            "mcp.githubcopilot.add_issue_comment",
749            "github.comment_on_issue",
750        ])
751        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.comment_on_issue"))
752    })?;
753    Ok(GithubToolSet {
754        server_name,
755        list_issues,
756        get_issue,
757        create_issue,
758        comment_on_issue,
759    })
760}
761
762fn map_namespaced_to_raw_tool(
763    tools: &[McpRemoteTool],
764    namespaced_name: &str,
765) -> anyhow::Result<String> {
766    tools
767        .iter()
768        .find(|row| row.namespaced_name == namespaced_name)
769        .map(|row| row.tool_name.clone())
770        .ok_or_else(|| anyhow::anyhow!("failed to map MCP tool `{namespaced_name}` to raw tool"))
771}
772
773async fn find_matching_issue(
774    state: &AppState,
775    tools: &GithubToolSet,
776    owner_repo: &(&str, &str),
777    draft: &BugMonitorDraftRecord,
778) -> anyhow::Result<Option<GithubIssue>> {
779    let mut issues = call_list_issues(state, tools, owner_repo).await?;
780    if let Some(existing_number) = draft.issue_number {
781        if let Some(existing) = issues
782            .iter()
783            .find(|row| row.number == existing_number)
784            .cloned()
785        {
786            return Ok(Some(existing));
787        }
788        if let Ok(issue) = call_get_issue(state, tools, owner_repo, existing_number).await {
789            return Ok(Some(issue));
790        }
791    }
792    let marker = fingerprint_marker(&draft.fingerprint);
793    issues.sort_by(|a, b| b.number.cmp(&a.number));
794    let exact_marker = issues
795        .iter()
796        .find(|issue| issue.body.contains(&marker))
797        .cloned();
798    if exact_marker.is_some() {
799        return Ok(exact_marker);
800    }
801    let normalized_title = draft
802        .title
803        .as_deref()
804        .map(|value| value.trim().to_ascii_lowercase())
805        .unwrap_or_default();
806    Ok(issues.into_iter().find(|issue| {
807        issue.title.trim().eq_ignore_ascii_case(&normalized_title)
808            || issue.body.contains(&draft.fingerprint)
809    }))
810}
811
812async fn successful_post_by_idempotency(
813    state: &AppState,
814    idempotency_key: &str,
815) -> Option<BugMonitorPostRecord> {
816    state
817        .bug_monitor_posts
818        .read()
819        .await
820        .values()
821        .find(|row| row.idempotency_key == idempotency_key && row.status == "posted")
822        .cloned()
823}
824
825async fn successful_post_for_draft(
826    state: &AppState,
827    draft_id: &str,
828    evidence_digest: Option<&str>,
829) -> Option<BugMonitorPostRecord> {
830    let mut rows = state.list_bug_monitor_posts(200).await;
831    rows.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
832    rows.into_iter().find(|row| {
833        row.draft_id == draft_id
834            && row.status == "posted"
835            && match evidence_digest {
836                Some(expected) => row.evidence_digest.as_deref() == Some(expected),
837                None => true,
838            }
839    })
840}
841
842fn failed_post_suppresses_create(
843    draft: &BugMonitorDraftRecord,
844    post: &BugMonitorPostRecord,
845) -> bool {
846    post.repo == draft.repo
847        && post.fingerprint == draft.fingerprint
848        && post.status == "failed"
849        && (post.operation == "create_issue"
850            || (post.operation == "auto_post" && !post_failure_is_preflight_only(post)))
851}
852
853fn post_failure_is_preflight_only(post: &BugMonitorPostRecord) -> bool {
854    let error = post
855        .error
856        .as_deref()
857        .unwrap_or_default()
858        .to_ascii_lowercase();
859    error.contains("not ready")
860        || error.contains("disabled")
861        || error.contains("paused")
862        || error.contains("provider/model")
863        || error.contains("selected mcp server")
864        || error.contains("target repo")
865}
866
867async fn latest_failed_create_post_for_draft(
868    state: &AppState,
869    draft: &BugMonitorDraftRecord,
870) -> Option<BugMonitorPostRecord> {
871    let mut rows = state
872        .bug_monitor_posts
873        .read()
874        .await
875        .values()
876        .filter(|post| failed_post_suppresses_create(draft, post))
877        .cloned()
878        .collect::<Vec<_>>();
879    rows.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
880    rows.into_iter().next()
881}
882
883/// Hashes the IDENTITY of the failure being reported — not the
884/// execution metadata of how we triaged it on a particular pass.
885/// Excluded: `triage_run_id` (recreated for stale/blocked triages,
886/// drove the #69-#194 spam), `incident.run_id` / `session_id`
887/// (redundant with fingerprint), `occurrence_count` (removed in #48).
888fn compute_evidence_digest(
889    draft: &BugMonitorDraftRecord,
890    incident: Option<&crate::BugMonitorIncidentRecord>,
891) -> String {
892    let _ = incident;
893    sha256_hex(&[
894        draft.repo.as_str(),
895        draft.fingerprint.as_str(),
896        draft.title.as_deref().unwrap_or(""),
897        draft.detail.as_deref().unwrap_or(""),
898    ])
899}
900
901fn build_idempotency_key(repo: &str, fingerprint: &str, operation: &str, digest: &str) -> String {
902    sha256_hex(&[repo, fingerprint, operation, digest])
903}
904
905fn build_issue_body(
906    draft: &BugMonitorDraftRecord,
907    incident: Option<&crate::BugMonitorIncidentRecord>,
908    matched_closed_issue: Option<&GithubIssue>,
909    evidence_digest: &str,
910) -> String {
911    let mut lines = Vec::new();
912    if let Some(detail) = draft.detail.as_deref() {
913        lines.push(truncate_text(detail, 4_000));
914    }
915    if let Some(run_id) = draft.triage_run_id.as_deref() {
916        if !lines.is_empty() {
917            lines.push(String::new());
918        }
919        lines.push(format!("triage_run_id: {run_id}"));
920    }
921    if let Some(issue) = matched_closed_issue {
922        lines.push(format!(
923            "previous_closed_issue: #{} ({})",
924            issue.number, issue.state
925        ));
926    }
927    if let Some(incident) = incident {
928        lines.push(format!("incident_id: {}", incident.incident_id));
929        if let Some(event_type) = Some(incident.event_type.as_str()) {
930            lines.push(format!("event_type: {event_type}"));
931        }
932        if !incident.workspace_root.trim().is_empty() {
933            lines.push(format!("local_directory: {}", incident.workspace_root));
934        }
935    }
936    if let Some(logs) = fallback_issue_logs(draft, incident) {
937        lines.push(String::new());
938        lines.push("### Logs".to_string());
939        lines.push("```".to_string());
940        lines.push(logs);
941        lines.push("```".to_string());
942    }
943    let evidence_refs = fallback_issue_evidence_refs(draft, incident);
944    if !evidence_refs.is_empty() {
945        lines.push(String::new());
946        lines.push("### Evidence".to_string());
947        for evidence_ref in evidence_refs {
948            lines.push(format!("- {evidence_ref}"));
949        }
950    }
951    if let Some(incident) = incident {
952        let mut metadata = Vec::new();
953        if let Some(run_id) = incident.run_id.as_deref() {
954            metadata.push(format!("run_id: {run_id}"));
955        }
956        if let Some(session_id) = incident.session_id.as_deref() {
957            metadata.push(format!("session_id: {session_id}"));
958        }
959        if let Some(correlation_id) = incident.correlation_id.as_deref() {
960            metadata.push(format!("correlation_id: {correlation_id}"));
961        }
962        if let Some(component) = incident.component.as_deref() {
963            metadata.push(format!("component: {component}"));
964        }
965        if let Some(level) = incident.level.as_deref() {
966            metadata.push(format!("level: {level}"));
967        }
968        if incident.occurrence_count > 1 {
969            let occurrence_count = incident.occurrence_count;
970            metadata.push(format!("occurrence_count: {occurrence_count}"));
971        }
972        if let Some(last_seen_at_ms) = incident.last_seen_at_ms {
973            metadata.push(format!(
974                "last_seen_at_ms: {}",
975                format_bug_monitor_ms(last_seen_at_ms)
976            ));
977        }
978        if !metadata.is_empty() {
979            lines.push(String::new());
980            lines.push("### Diagnostic metadata".to_string());
981            lines.extend(metadata);
982        }
983    }
984    let mut triage_signal = Vec::new();
985    if let Some(confidence) = draft.confidence.as_deref() {
986        triage_signal.push(format!("confidence: {confidence}"));
987    }
988    if let Some(risk_level) = draft.risk_level.as_deref() {
989        triage_signal.push(format!("risk_level: {risk_level}"));
990    }
991    if let Some(expected_destination) = draft.expected_destination.as_deref() {
992        triage_signal.push(format!("expected_destination: {expected_destination}"));
993    }
994    if let Some(gate) = draft.quality_gate.as_ref() {
995        if !gate.passed {
996            triage_signal.push("quality_gate_status: blocked".to_string());
997            if !gate.missing.is_empty() {
998                triage_signal.push("quality_gate_missing:".to_string());
999                for missing in gate
1000                    .missing
1001                    .iter()
1002                    .take(ISSUE_BODY_QUALITY_GATE_MISSING_LIMIT)
1003                {
1004                    triage_signal.push(format!("- {missing}"));
1005                }
1006            }
1007            if let Some(reason) = gate.blocked_reason.as_deref() {
1008                triage_signal.push(format!(
1009                    "quality_gate_reason: {}",
1010                    truncate_text(reason, 500)
1011                ));
1012            }
1013        }
1014    }
1015    if !triage_signal.is_empty() {
1016        lines.push(String::new());
1017        lines.push("### Triage signal".to_string());
1018        lines.extend(triage_signal);
1019    }
1020    if let Some(status) = fallback_issue_triage_status(draft.github_status.as_deref()) {
1021        lines.push(String::new());
1022        lines.push("### Triage status".to_string());
1023        lines.push(format!("triage_status: {status}"));
1024        if status == "triage_timed_out" {
1025            if let Some(diagnostics) = draft
1026                .last_post_error
1027                .as_deref()
1028                .map(str::trim)
1029                .filter(|s| !s.is_empty())
1030                .filter(|s| s.contains('\n'))
1031            {
1032                lines.push(String::new());
1033                lines.push("### Triage timeout details".to_string());
1034                for line in diagnostics
1035                    .lines()
1036                    .take(ISSUE_BODY_TRIAGE_TIMEOUT_DETAIL_LINES)
1037                {
1038                    lines.push(line.to_string());
1039                }
1040            }
1041        }
1042    }
1043    lines.push(String::new());
1044    let markers = [
1045        fingerprint_marker(&draft.fingerprint),
1046        evidence_marker(evidence_digest),
1047    ];
1048    let marker_text = markers.join("\n");
1049    let body_budget = ISSUE_BODY_BYTE_BUDGET
1050        .saturating_sub(marker_text.len())
1051        .saturating_sub(ISSUE_BODY_MARKER_SAFE_SPACE);
1052    let body = truncate_text(&lines.join("\n"), body_budget);
1053    format!("{body}\n{marker_text}")
1054}
1055
1056fn fallback_issue_logs(
1057    draft: &BugMonitorDraftRecord,
1058    incident: Option<&crate::BugMonitorIncidentRecord>,
1059) -> Option<String> {
1060    let rows = incident
1061        .map(|row| {
1062            row.excerpt
1063                .iter()
1064                .filter_map(|line| normalize_issue_body_line(line))
1065                .take(ISSUE_BODY_LOG_LINES)
1066                .collect::<Vec<_>>()
1067        })
1068        .filter(|rows| !rows.is_empty())
1069        .unwrap_or_else(|| {
1070            draft
1071                .detail
1072                .as_deref()
1073                .unwrap_or_default()
1074                .lines()
1075                .filter_map(normalize_issue_body_line)
1076                .take(ISSUE_BODY_LOG_FALLBACK_LINES)
1077                .collect::<Vec<_>>()
1078        });
1079    if rows.is_empty() {
1080        None
1081    } else {
1082        Some(truncate_text(&rows.join("\n"), ISSUE_BODY_LOG_CHAR_BUDGET))
1083    }
1084}
1085
1086fn fallback_issue_evidence_refs(
1087    draft: &BugMonitorDraftRecord,
1088    incident: Option<&crate::BugMonitorIncidentRecord>,
1089) -> Vec<String> {
1090    // Evidence references are capped so issue bodies stay skimmable.
1091    // Full evidence graphs stay in artifacts/run logs and can be fetched by ID.
1092    let mut refs = BTreeSet::new();
1093    for evidence_ref in draft.evidence_refs.iter() {
1094        if let Some(row) = normalize_issue_body_line(evidence_ref) {
1095            refs.insert(row);
1096        }
1097    }
1098    if let Some(incident) = incident {
1099        for evidence_ref in incident.evidence_refs.iter() {
1100            if let Some(row) = normalize_issue_body_line(evidence_ref) {
1101                refs.insert(row);
1102            }
1103        }
1104    }
1105    refs.into_iter()
1106        .take(ISSUE_BODY_EVIDENCE_REF_LIMIT)
1107        .collect()
1108}
1109
1110fn normalize_issue_body_line(value: impl AsRef<str>) -> Option<String> {
1111    let value = value.as_ref().trim();
1112    (!value.is_empty()).then(|| truncate_text(value, 1_500))
1113}
1114
1115fn format_bug_monitor_ms(ms: u64) -> String {
1116    chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
1117        .map(|value| value.to_rfc3339())
1118        .unwrap_or_else(|| ms.to_string())
1119}
1120
1121fn fallback_issue_triage_status(status: Option<&str>) -> Option<&str> {
1122    match status {
1123        Some("triage_timed_out" | "triage_pending" | "github_post_failed") => status,
1124        _ => None,
1125    }
1126}
1127
1128fn build_comment_body(
1129    draft: &BugMonitorDraftRecord,
1130    incident: Option<&crate::BugMonitorIncidentRecord>,
1131    issue_number: u64,
1132    evidence_digest: &str,
1133    issue_draft: Option<&Value>,
1134) -> String {
1135    let mut lines = vec![format!(
1136        "New Bug Monitor evidence detected for #{issue_number}."
1137    )];
1138    if let Some(summary) = issue_draft
1139        .and_then(|row| row.get("what_happened"))
1140        .and_then(Value::as_str)
1141        .filter(|value| !value.trim().is_empty())
1142    {
1143        lines.push(String::new());
1144        lines.push(truncate_text(summary, 1_500));
1145    } else {
1146        // No LLM-produced narrative for this occurrence (triage timed
1147        // out, hasn't run yet, or didn't produce a `what_happened`).
1148        // Don't dump the verbose event payload from `draft.detail` —
1149        // it just repeats the original issue body and adds noise.
1150        // Emit a focused recurrence summary instead.
1151        lines.push(String::new());
1152        lines.push(
1153            crate::bug_monitor::comment_summary::build_comment_recurrence_summary(draft, incident),
1154        );
1155    }
1156    if let Some(logs) = issue_draft
1157        .and_then(|row| row.get("logs"))
1158        .and_then(Value::as_array)
1159        .filter(|rows| !rows.is_empty())
1160    {
1161        lines.push(String::new());
1162        lines.push("logs:".to_string());
1163        for line in logs.iter().filter_map(Value::as_str).take(6) {
1164            lines.push(format!("  {line}"));
1165        }
1166    }
1167    if let Some(incident) = incident {
1168        lines.push(String::new());
1169        lines.push(format!("incident_id: {}", incident.incident_id));
1170        if let Some(run_id) = incident.run_id.as_deref() {
1171            lines.push(format!("run_id: {run_id}"));
1172        }
1173        if let Some(session_id) = incident.session_id.as_deref() {
1174            lines.push(format!("session_id: {session_id}"));
1175        }
1176    }
1177    if let Some(run_id) = draft.triage_run_id.as_deref() {
1178        lines.push(format!("triage_run_id: {run_id}"));
1179    }
1180    lines.push(String::new());
1181    lines.push(evidence_marker(evidence_digest));
1182    lines.join("\n")
1183}
1184
1185fn fingerprint_marker(fingerprint: &str) -> String {
1186    format!("<!-- tandem:fingerprint:v1:{fingerprint} -->")
1187}
1188
1189fn evidence_marker(digest: &str) -> String {
1190    format!("<!-- tandem:evidence:v1:{digest} -->")
1191}
1192
1193fn split_owner_repo(repo: &str) -> anyhow::Result<(&str, &str)> {
1194    let mut parts = repo.split('/');
1195    let owner = parts
1196        .next()
1197        .filter(|value| !value.trim().is_empty())
1198        .ok_or_else(|| anyhow::anyhow!("invalid owner/repo value"))?;
1199    let repo_name = parts
1200        .next()
1201        .filter(|value| !value.trim().is_empty())
1202        .ok_or_else(|| anyhow::anyhow!("invalid owner/repo value"))?;
1203    if parts.next().is_some() {
1204        anyhow::bail!("invalid owner/repo value");
1205    }
1206    Ok((owner, repo_name))
1207}
1208
1209async fn call_list_issues(
1210    state: &AppState,
1211    tools: &GithubToolSet,
1212    (owner, repo): &(&str, &str),
1213) -> anyhow::Result<Vec<GithubIssue>> {
1214    let result = state
1215        .mcp
1216        .call_tool(
1217            &tools.server_name,
1218            &tools.list_issues,
1219            json!({
1220                "owner": owner,
1221                "repo": repo,
1222                "state": "all",
1223                "perPage": 100
1224            }),
1225        )
1226        .await
1227        .map_err(anyhow::Error::msg)?;
1228    Ok(extract_issues_from_tool_result(&result))
1229}
1230
1231async fn call_get_issue(
1232    state: &AppState,
1233    tools: &GithubToolSet,
1234    (owner, repo): &(&str, &str),
1235    issue_number: u64,
1236) -> anyhow::Result<GithubIssue> {
1237    let result = state
1238        .mcp
1239        .call_tool(
1240            &tools.server_name,
1241            &tools.get_issue,
1242            json!({
1243                "owner": owner,
1244                "repo": repo,
1245                "issue_number": issue_number
1246            }),
1247        )
1248        .await
1249        .map_err(anyhow::Error::msg)?;
1250    extract_issues_from_tool_result(&result)
1251        .into_iter()
1252        .find(|issue| issue.number == issue_number)
1253        .ok_or_else(|| anyhow::anyhow!("GitHub issue #{issue_number} was not returned"))
1254}
1255
1256async fn call_create_issue(
1257    state: &AppState,
1258    tools: &GithubToolSet,
1259    (owner, repo): &(&str, &str),
1260    title: &str,
1261    body: &str,
1262) -> anyhow::Result<GithubIssue> {
1263    let preferred = json!({
1264        "method": "create",
1265        "owner": owner,
1266        "repo": repo,
1267        "title": title,
1268        "body": body,
1269        "labels": [BUG_MONITOR_LABEL],
1270    });
1271    let fallback = json!({
1272        "owner": owner,
1273        "repo": repo,
1274        "title": title,
1275        "body": body,
1276        "labels": [BUG_MONITOR_LABEL],
1277    });
1278    let first = state
1279        .mcp
1280        .call_tool(&tools.server_name, &tools.create_issue, preferred)
1281        .await;
1282    let result = match first {
1283        Ok(result) => result,
1284        Err(_) => state
1285            .mcp
1286            .call_tool(&tools.server_name, &tools.create_issue, fallback)
1287            .await
1288            .map_err(anyhow::Error::msg)?,
1289    };
1290    if let Some(issue) = extract_issues_from_tool_result(&result).into_iter().next() {
1291        return Ok(issue);
1292    }
1293    let fingerprint_marker = body
1294        .lines()
1295        .find(|line| line.contains("<!-- tandem:fingerprint:v1:"));
1296    find_created_issue_after_create(state, tools, &(owner, repo), title, fingerprint_marker).await
1297}
1298
1299async fn find_created_issue_after_create(
1300    state: &AppState,
1301    tools: &GithubToolSet,
1302    owner_repo: &(&str, &str),
1303    title: &str,
1304    fingerprint_marker: Option<&str>,
1305) -> anyhow::Result<GithubIssue> {
1306    let mut last_error = None;
1307    for delay_ms in [0_u64, 250, 750, 1500] {
1308        if delay_ms > 0 {
1309            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1310        }
1311        match call_list_issues(state, tools, owner_repo).await {
1312            Ok(issues) => {
1313                if let Some(issue) = issues.into_iter().find(|issue| {
1314                    issue.title.trim() == title.trim()
1315                        || fingerprint_marker.is_some_and(|marker| issue.body.contains(marker))
1316                }) {
1317                    return Ok(issue);
1318                }
1319            }
1320            Err(error) => {
1321                last_error = Some(error);
1322            }
1323        }
1324    }
1325    if let Some(error) = last_error {
1326        return Err(error).context("GitHub issue creation returned no issue payload");
1327    }
1328    Err(anyhow::anyhow!(
1329        "GitHub issue creation returned no issue payload"
1330    ))
1331}
1332
1333async fn call_add_issue_comment(
1334    state: &AppState,
1335    tools: &GithubToolSet,
1336    (owner, repo): &(&str, &str),
1337    issue_number: u64,
1338    body: &str,
1339) -> anyhow::Result<GithubComment> {
1340    let result = state
1341        .mcp
1342        .call_tool(
1343            &tools.server_name,
1344            &tools.comment_on_issue,
1345            json!({
1346                "owner": owner,
1347                "repo": repo,
1348                "issue_number": issue_number,
1349                "body": body
1350            }),
1351        )
1352        .await
1353        .map_err(anyhow::Error::msg)?;
1354    extract_comments_from_tool_result(&result)
1355        .into_iter()
1356        .next()
1357        .ok_or_else(|| anyhow::anyhow!("GitHub comment creation returned no comment payload"))
1358}
1359
1360fn extract_issues_from_tool_result(result: &tandem_types::ToolResult) -> Vec<GithubIssue> {
1361    let mut out = Vec::new();
1362    for candidate in tool_result_values(result) {
1363        collect_issues(&candidate, &mut out);
1364    }
1365    dedupe_issues(out)
1366}
1367
1368fn extract_comments_from_tool_result(result: &tandem_types::ToolResult) -> Vec<GithubComment> {
1369    let mut out = Vec::new();
1370    for candidate in tool_result_values(result) {
1371        collect_comments(&candidate, &mut out);
1372    }
1373    dedupe_comments(out)
1374}
1375
1376fn tool_result_values(result: &tandem_types::ToolResult) -> Vec<Value> {
1377    let mut values = Vec::new();
1378    if let Some(value) = result.metadata.get("result") {
1379        values.push(value.clone());
1380    }
1381    if let Ok(parsed) = serde_json::from_str::<Value>(&result.output) {
1382        values.push(parsed);
1383    }
1384    values
1385}
1386
1387fn collect_issues(value: &Value, out: &mut Vec<GithubIssue>) {
1388    match value {
1389        Value::Object(map) => {
1390            let issue_number = map
1391                .get("number")
1392                .or_else(|| map.get("issue_number"))
1393                .and_then(Value::as_u64);
1394            let title = map
1395                .get("title")
1396                .and_then(Value::as_str)
1397                .unwrap_or_default()
1398                .to_string();
1399            let body = map
1400                .get("body")
1401                .and_then(Value::as_str)
1402                .unwrap_or_default()
1403                .to_string();
1404            let state = map
1405                .get("state")
1406                .and_then(Value::as_str)
1407                .unwrap_or_default()
1408                .to_string();
1409            let html_url = map
1410                .get("html_url")
1411                .or_else(|| map.get("url"))
1412                .and_then(Value::as_str)
1413                .map(|value| value.to_string());
1414            if let Some(number) = issue_number {
1415                if !title.is_empty() || !body.is_empty() || !state.is_empty() {
1416                    out.push(GithubIssue {
1417                        number,
1418                        title,
1419                        body,
1420                        state,
1421                        html_url,
1422                    });
1423                }
1424            }
1425            for nested in map.values() {
1426                collect_issues(nested, out);
1427            }
1428        }
1429        Value::Array(rows) => {
1430            for row in rows {
1431                collect_issues(row, out);
1432            }
1433        }
1434        _ => {}
1435    }
1436}
1437
1438fn collect_comments(value: &Value, out: &mut Vec<GithubComment>) {
1439    match value {
1440        Value::Object(map) => {
1441            if map.contains_key("id") && (map.contains_key("html_url") || map.contains_key("url")) {
1442                out.push(GithubComment {
1443                    id: map.get("id").map(|value| {
1444                        value
1445                            .as_str()
1446                            .map(|row| row.to_string())
1447                            .unwrap_or_else(|| value.to_string())
1448                    }),
1449                    html_url: map
1450                        .get("html_url")
1451                        .or_else(|| map.get("url"))
1452                        .and_then(Value::as_str)
1453                        .map(|value| value.to_string()),
1454                });
1455            }
1456            for nested in map.values() {
1457                collect_comments(nested, out);
1458            }
1459        }
1460        Value::Array(rows) => {
1461            for row in rows {
1462                collect_comments(row, out);
1463            }
1464        }
1465        _ => {}
1466    }
1467}
1468
1469fn dedupe_issues(rows: Vec<GithubIssue>) -> Vec<GithubIssue> {
1470    let mut out = Vec::new();
1471    let mut seen = std::collections::HashSet::new();
1472    for row in rows {
1473        if seen.insert(row.number) {
1474            out.push(row);
1475        }
1476    }
1477    out
1478}
1479
1480fn dedupe_comments(rows: Vec<GithubComment>) -> Vec<GithubComment> {
1481    let mut out = Vec::new();
1482    let mut seen = std::collections::HashSet::new();
1483    for row in rows {
1484        let key = row.id.clone().or(row.html_url.clone()).unwrap_or_default();
1485        if !key.is_empty() && seen.insert(key) {
1486            out.push(row);
1487        }
1488    }
1489    out
1490}
1491
1492/// Run the deterministic error-string → workspace-source grep and
1493/// append a markdown "Error provenance" section to the issue body.
1494/// Best-effort: any failure to locate provenance just leaves the body
1495/// unchanged. The added section is bounded; see `error_provenance`.
1496///
1497/// Each silent-skip path emits a `tracing::info!` so operators can tell
1498/// from logs *why* an issue body shipped without an Error provenance
1499/// section — currently a recurring symptom and the only signal that
1500/// distinguishes "no error message picked", "workspace path not
1501/// accessible to this process", and "grep returned zero hits".
1502async fn append_error_provenance_section(
1503    state: &AppState,
1504    body: String,
1505    draft: &BugMonitorDraftRecord,
1506    incident: Option<&BugMonitorIncidentRecord>,
1507) -> String {
1508    let incident_id = incident.map(|row| row.incident_id.as_str()).unwrap_or("");
1509    let draft_id = draft.draft_id.as_str();
1510    let mut combined = body;
1511    let section = fallback_tool_evidence_section(state, incident, draft.triage_run_id.as_deref());
1512    if !section.trim().is_empty() {
1513        if !combined.ends_with('\n') {
1514            combined.push('\n');
1515        }
1516        combined.push('\n');
1517        combined.push_str(&section);
1518    }
1519    let Some(error_message) = pick_error_message_for_provenance(draft, incident) else {
1520        tracing::info!(
1521            incident_id = %incident_id,
1522            draft_id = %draft_id,
1523            reason = "no_error_message",
1524            "skipping error provenance: no usable error message on draft/incident",
1525        );
1526        return combined;
1527    };
1528    let raw_workspace_root = incident
1529        .map(|row| row.workspace_root.as_str())
1530        .unwrap_or("");
1531    let workspace_root = pick_workspace_root_for_provenance(incident);
1532    let Some(workspace_root) = workspace_root else {
1533        tracing::info!(
1534            incident_id = %incident_id,
1535            draft_id = %draft_id,
1536            reason = "workspace_root_inaccessible",
1537            workspace_root = %raw_workspace_root,
1538            "skipping error provenance: workspace_root missing, not absolute, or not present on this process's filesystem",
1539        );
1540        return combined;
1541    };
1542    let hits = locate_error_provenance(&workspace_root, &error_message).await;
1543    let Some(section) = render_provenance_section(&hits) else {
1544        let preview = error_message.chars().take(160).collect::<String>();
1545        tracing::info!(
1546            incident_id = %incident_id,
1547            draft_id = %draft_id,
1548            reason = "no_grep_hits",
1549            workspace_root = %workspace_root.display(),
1550            error_message_preview = %preview,
1551            hit_count = hits.len(),
1552            "skipping error provenance: git grep returned no usable hits in the workspace for the error message",
1553        );
1554        return combined;
1555    };
1556    tracing::info!(
1557        incident_id = %incident_id,
1558        draft_id = %draft_id,
1559        workspace_root = %workspace_root.display(),
1560        hit_count = hits.len(),
1561        "appended error provenance section to issue body",
1562    );
1563    if !combined.ends_with('\n') {
1564        combined.push('\n');
1565    }
1566    combined.push('\n');
1567    combined.push_str(&section);
1568    combined
1569}
1570
1571fn fallback_tool_evidence_section(
1572    state: &AppState,
1573    incident: Option<&BugMonitorIncidentRecord>,
1574    draft_run_id: Option<&str>,
1575) -> String {
1576    // Show only the most useful recent tool calls.
1577    // This preserves debuggability without flooding the issue with full event history.
1578    let run_id = incident
1579        .and_then(|row| row.run_id.as_deref())
1580        .or(draft_run_id)
1581        .filter(|value| !value.trim().is_empty());
1582    let Some(run_id) = run_id else {
1583        return String::new();
1584    };
1585
1586    let events = fs::read_to_string(context_run_events_path(state, run_id))
1587        .ok()
1588        .map(|content| {
1589            content
1590                .lines()
1591                .map(str::trim)
1592                .filter(|line| !line.is_empty())
1593                .filter_map(|line| serde_json::from_str::<Value>(line).ok())
1594                .collect::<Vec<_>>()
1595        })
1596        .unwrap_or_default();
1597
1598    let rows = events
1599        .into_iter()
1600        .filter_map(|row| {
1601            let event_type = row.get("type").and_then(Value::as_str)?;
1602            if event_type != "tool_effect_recorded" {
1603                return None;
1604            }
1605            row.get("payload")?
1606                .get("record")
1607                .and_then(|row| serde_json::from_value::<ToolEffectLedgerRecord>(row.clone()).ok())
1608        })
1609        .filter_map(format_tool_effect_record)
1610        .take(ISSUE_BODY_TOOL_EVIDENCE_LIMIT)
1611        .collect::<Vec<_>>();
1612    if rows.is_empty() {
1613        return String::new();
1614    }
1615    let mut lines = vec!["### Tool evidence".to_string()];
1616    lines.extend(rows);
1617    lines.join("\n")
1618}
1619
1620fn format_tool_effect_record(record: ToolEffectLedgerRecord) -> Option<String> {
1621    let status = serde_json::to_string(&record.status)
1622        .map(|value| value.trim_matches('"').to_string())
1623        .unwrap_or_else(|_| "unknown".to_string());
1624    let phase = serde_json::to_string(&record.phase)
1625        .map(|value| value.trim_matches('"').to_string())
1626        .unwrap_or_else(|_| "unknown".to_string());
1627
1628    let mut details = Vec::new();
1629    if let Some(path) = record.args_summary.get("path").and_then(Value::as_str) {
1630        details.push(format!("path={path}"));
1631    }
1632    if let Some(url) = record.args_summary.get("url").and_then(Value::as_str) {
1633        details.push(format!("url={url}"));
1634    }
1635    if let Some(command_hash) = record
1636        .args_summary
1637        .get("command_hash")
1638        .and_then(Value::as_str)
1639    {
1640        details.push(format!("command_hash={command_hash}"));
1641    }
1642    if let Some(query_hash) = record
1643        .args_summary
1644        .get("query_hash")
1645        .and_then(Value::as_str)
1646    {
1647        details.push(format!("query_hash={query_hash}"));
1648    }
1649
1650    let mut result = Vec::new();
1651    if let Some(error) = record.error.as_ref() {
1652        let error = truncate_text(error, ISSUE_BODY_TOOL_ERROR_CHAR_BUDGET);
1653        if !error.is_empty() {
1654            result.push(format!("error={error}"));
1655        }
1656    }
1657    if let Some(value) = record
1658        .result_summary
1659        .as_ref()
1660        .and_then(|value| serde_json::to_string(value).ok())
1661    {
1662        let value = truncate_text(&value, ISSUE_BODY_TOOL_RESULT_CHAR_BUDGET);
1663        if !value.is_empty() {
1664            result.push(format!("result={value}"));
1665        }
1666    }
1667
1668    details.extend(result);
1669    let details = if details.is_empty() {
1670        String::new()
1671    } else {
1672        format!(" ({})", details.join(", "))
1673    };
1674    Some(truncate_text(
1675        &format!("- {} {} / {}{}", record.tool, phase, status, details),
1676        ISSUE_BODY_TOOL_RECORD_BUDGET,
1677    ))
1678}
1679
1680fn pick_error_message_for_provenance(
1681    draft: &BugMonitorDraftRecord,
1682    incident: Option<&BugMonitorIncidentRecord>,
1683) -> Option<String> {
1684    // Prefer fields written at incident/draft creation time. Avoid
1685    // `last_error` and `last_post_error` because the triage deadline
1686    // task rewrites those with the multi-line timeout diagnostics
1687    // ("triage run X did not reach a terminal status within …\n
1688    // timeout_ms: …"). Grepping the codebase for that diagnostic
1689    // text always returns no hits, so the Error provenance section
1690    // would silently disappear on every triage timeout — exactly
1691    // the issues we most need provenance for.
1692    let candidates = [
1693        incident.and_then(|row| {
1694            row.excerpt
1695                .iter()
1696                .find(|line| !line.trim().is_empty())
1697                .cloned()
1698        }),
1699        draft.detail.clone(),
1700        incident.and_then(|row| row.detail.clone()),
1701        incident.and_then(|row| extract_error_after_colon(&row.title)),
1702        incident.map(|row| row.title.clone()),
1703        draft.title.clone(),
1704    ];
1705    candidates
1706        .into_iter()
1707        .flatten()
1708        .map(|value| value.trim().to_string())
1709        .find(|value| !value.is_empty())
1710}
1711
1712/// Pull the trailing-colon portion out of a bug-monitor incident
1713/// title so that "Workflow X failed at Y: real error here" yields
1714/// "real error here". Uses leftmost split so titles whose error
1715/// itself contains colons survive intact. The full title still
1716/// serves as a fallback when the suffix is too short to be useful.
1717fn extract_error_after_colon(title: &str) -> Option<String> {
1718    let trimmed = title.trim();
1719    if trimmed.is_empty() {
1720        return None;
1721    }
1722    let suffix = trimmed.split_once(':').map(|(_, suffix)| suffix.trim());
1723    suffix
1724        .filter(|s| !s.is_empty() && s.split_whitespace().count() >= 3)
1725        .map(str::to_string)
1726}
1727
1728fn pick_workspace_root_for_provenance(
1729    incident: Option<&BugMonitorIncidentRecord>,
1730) -> Option<std::path::PathBuf> {
1731    let raw = incident.map(|row| row.workspace_root.trim()).unwrap_or("");
1732    if raw.is_empty() {
1733        return None;
1734    }
1735    let path = Path::new(raw);
1736    if !path.is_absolute() {
1737        return None;
1738    }
1739    if !path.exists() {
1740        return None;
1741    }
1742    Some(path.to_path_buf())
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748    use tandem_types::ToolResult;
1749
1750    #[test]
1751    fn build_issue_body_includes_hidden_markers() {
1752        let draft = BugMonitorDraftRecord {
1753            draft_id: "draft-1".to_string(),
1754            fingerprint: "abc123".to_string(),
1755            repo: "acme/platform".to_string(),
1756            status: "draft_ready".to_string(),
1757            created_at_ms: 1,
1758            triage_run_id: Some("triage-1".to_string()),
1759            issue_number: None,
1760            title: Some("session.error detected".to_string()),
1761            detail: Some("summary".to_string()),
1762            ..BugMonitorDraftRecord::default()
1763        };
1764        let body = build_issue_body(&draft, None, None, "digest-1");
1765        assert!(body.contains("<!-- tandem:fingerprint:v1:abc123 -->"));
1766        assert!(body.contains("<!-- tandem:evidence:v1:digest-1 -->"));
1767        assert!(body.contains("triage_run_id: triage-1"));
1768    }
1769
1770    #[test]
1771    fn build_issue_body_renders_incident_excerpt_as_fenced_logs() {
1772        let draft = BugMonitorDraftRecord {
1773            draft_id: "draft-logs".to_string(),
1774            fingerprint: "log-fingerprint".to_string(),
1775            repo: "acme/platform".to_string(),
1776            status: "draft_ready".to_string(),
1777            created_at_ms: 1,
1778            detail: Some("fallback detail".to_string()),
1779            ..BugMonitorDraftRecord::default()
1780        };
1781        let incident = crate::BugMonitorIncidentRecord {
1782            incident_id: "incident-logs".to_string(),
1783            fingerprint: draft.fingerprint.clone(),
1784            event_type: "workflow.run.failed".to_string(),
1785            status: "triage_queued".to_string(),
1786            repo: draft.repo.clone(),
1787            workspace_root: "/tmp/acme".to_string(),
1788            title: "Workflow failed".to_string(),
1789            excerpt: vec![
1790                "first failure line".to_string(),
1791                "second failure line".to_string(),
1792            ],
1793            ..crate::BugMonitorIncidentRecord::default()
1794        };
1795        let body = build_issue_body(&draft, Some(&incident), None, "digest-logs");
1796        assert!(body.contains("### Logs\n```\nfirst failure line\nsecond failure line\n```"));
1797        assert!(body.contains("incident_id: incident-logs"));
1798        assert!(body.contains("event_type: workflow.run.failed"));
1799    }
1800
1801    #[test]
1802    fn build_issue_body_renders_deduped_evidence_refs() {
1803        let draft = BugMonitorDraftRecord {
1804            draft_id: "draft-evidence".to_string(),
1805            fingerprint: "evidence-fingerprint".to_string(),
1806            repo: "acme/platform".to_string(),
1807            status: "draft_ready".to_string(),
1808            created_at_ms: 1,
1809            evidence_refs: vec![
1810                "artifacts/shared.json".to_string(),
1811                "artifacts/draft-only.log".to_string(),
1812            ],
1813            ..BugMonitorDraftRecord::default()
1814        };
1815        let incident = crate::BugMonitorIncidentRecord {
1816            incident_id: "incident-evidence".to_string(),
1817            fingerprint: draft.fingerprint.clone(),
1818            event_type: "workflow.run.failed".to_string(),
1819            status: "triage_queued".to_string(),
1820            repo: draft.repo.clone(),
1821            workspace_root: "/tmp/acme".to_string(),
1822            title: "Workflow failed".to_string(),
1823            evidence_refs: vec![
1824                "artifacts/shared.json".to_string(),
1825                "artifacts/incident-only.log".to_string(),
1826            ],
1827            ..crate::BugMonitorIncidentRecord::default()
1828        };
1829        let body = build_issue_body(&draft, Some(&incident), None, "digest-evidence");
1830        assert!(body.contains("### Evidence"));
1831        assert_eq!(body.matches("- artifacts/shared.json").count(), 1);
1832        assert!(body.contains("- artifacts/draft-only.log"));
1833        assert!(body.contains("- artifacts/incident-only.log"));
1834    }
1835
1836    #[test]
1837    fn build_issue_body_renders_only_present_diagnostic_metadata() {
1838        let draft = BugMonitorDraftRecord {
1839            draft_id: "draft-metadata".to_string(),
1840            fingerprint: "metadata-fingerprint".to_string(),
1841            repo: "acme/platform".to_string(),
1842            status: "draft_ready".to_string(),
1843            created_at_ms: 1,
1844            ..BugMonitorDraftRecord::default()
1845        };
1846        let incident = crate::BugMonitorIncidentRecord {
1847            incident_id: "incident-metadata".to_string(),
1848            fingerprint: draft.fingerprint.clone(),
1849            event_type: "workflow.run.failed".to_string(),
1850            status: "triage_queued".to_string(),
1851            repo: draft.repo.clone(),
1852            workspace_root: "/tmp/acme".to_string(),
1853            title: "Workflow failed".to_string(),
1854            run_id: Some("run-1".to_string()),
1855            component: Some("automation_v2".to_string()),
1856            occurrence_count: 3,
1857            last_seen_at_ms: Some(1_777_485_515_668),
1858            ..crate::BugMonitorIncidentRecord::default()
1859        };
1860        let body = build_issue_body(&draft, Some(&incident), None, "digest-metadata");
1861        assert!(body.contains("### Diagnostic metadata"));
1862        assert!(body.contains("run_id: run-1"));
1863        assert!(body.contains("component: automation_v2"));
1864        assert!(body.contains("occurrence_count: 3"));
1865        assert!(body.contains("last_seen_at_ms: 2026-04-29T"));
1866        assert!(!body.contains("session_id:"));
1867        assert!(!body.contains("correlation_id:"));
1868        assert!(!body.contains("level:"));
1869    }
1870
1871    #[test]
1872    fn build_issue_body_renders_fallback_triage_status_for_known_states() {
1873        let mut draft = BugMonitorDraftRecord {
1874            draft_id: "draft-status".to_string(),
1875            fingerprint: "status-fingerprint".to_string(),
1876            repo: "acme/platform".to_string(),
1877            status: "draft_ready".to_string(),
1878            created_at_ms: 1,
1879            github_status: Some("triage_timed_out".to_string()),
1880            confidence: Some("medium".to_string()),
1881            risk_level: Some("medium".to_string()),
1882            expected_destination: Some("bug_monitor_issue_draft".to_string()),
1883            quality_gate: Some(crate::BugMonitorQualityGateReport {
1884                stage: "draft_to_proposal".to_string(),
1885                status: "blocked".to_string(),
1886                passed: false,
1887                passed_count: 2,
1888                total_count: 4,
1889                gates: Vec::new(),
1890                missing: vec!["research_performed".to_string()],
1891                blocked_reason: Some("triage timed out".to_string()),
1892            }),
1893            ..BugMonitorDraftRecord::default()
1894        };
1895        let body = build_issue_body(&draft, None, None, "digest-status");
1896        assert!(body.contains("### Triage signal"));
1897        assert!(body.contains("confidence: medium"));
1898        assert!(body.contains("quality_gate_status: blocked"));
1899        assert!(body.contains("- research_performed"));
1900        assert!(body.contains("quality_gate_reason: triage timed out"));
1901        assert!(body.contains("triage_status: triage_timed_out"));
1902
1903        draft.github_status = Some("issue_draft_ready".to_string());
1904        let body = build_issue_body(&draft, None, None, "digest-status");
1905        assert!(!body.contains("triage_status:"));
1906        draft.github_status = None;
1907        let body = build_issue_body(&draft, None, None, "digest-status");
1908        assert!(!body.contains("triage_status:"));
1909    }
1910
1911    #[test]
1912    fn build_issue_body_truncates_long_excerpt() {
1913        let draft = BugMonitorDraftRecord {
1914            draft_id: "draft-long".to_string(),
1915            fingerprint: "long-fingerprint".to_string(),
1916            repo: "acme/platform".to_string(),
1917            status: "draft_ready".to_string(),
1918            created_at_ms: 1,
1919            ..BugMonitorDraftRecord::default()
1920        };
1921        let incident = crate::BugMonitorIncidentRecord {
1922            incident_id: "incident-long".to_string(),
1923            fingerprint: draft.fingerprint.clone(),
1924            event_type: "workflow.run.failed".to_string(),
1925            status: "triage_queued".to_string(),
1926            repo: draft.repo.clone(),
1927            workspace_root: "/tmp/acme".to_string(),
1928            title: "Workflow failed".to_string(),
1929            excerpt: vec!["x".repeat(8_000)],
1930            ..crate::BugMonitorIncidentRecord::default()
1931        };
1932        let body = build_issue_body(&draft, Some(&incident), None, "digest-long");
1933        assert!(body.len() < 12_500);
1934        assert!(body.contains("<!-- tandem:evidence:v1:digest-long -->"));
1935    }
1936
1937    #[test]
1938    fn extract_issues_from_official_github_mcp_result() {
1939        let result = ToolResult {
1940            output: String::new(),
1941            metadata: json!({
1942                "result": {
1943                    "issues": [
1944                        {
1945                            "number": 42,
1946                            "title": "Bug Monitor issue",
1947                            "body": "details\n<!-- tandem:fingerprint:v1:deadbeef -->",
1948                            "state": "open",
1949                            "html_url": "https://github.com/acme/platform/issues/42"
1950                        }
1951                    ]
1952                }
1953            }),
1954        };
1955        let issues = extract_issues_from_tool_result(&result);
1956        assert_eq!(issues.len(), 1);
1957        assert_eq!(issues[0].number, 42);
1958        assert_eq!(issues[0].state, "open");
1959        assert!(issues[0].body.contains("deadbeef"));
1960    }
1961
1962    #[test]
1963    fn failed_create_posts_suppress_unsafe_create_retries() {
1964        let draft = BugMonitorDraftRecord {
1965            draft_id: "draft-1".to_string(),
1966            repo: "acme/platform".to_string(),
1967            fingerprint: "fp-create".to_string(),
1968            ..Default::default()
1969        };
1970        let failed_create = BugMonitorPostRecord {
1971            post_id: "post-create".to_string(),
1972            repo: draft.repo.clone(),
1973            fingerprint: draft.fingerprint.clone(),
1974            operation: "create_issue".to_string(),
1975            status: "failed".to_string(),
1976            ..Default::default()
1977        };
1978        let failed_auto_post = BugMonitorPostRecord {
1979            operation: "auto_post".to_string(),
1980            ..failed_create.clone()
1981        };
1982        let failed_preflight_auto_post = BugMonitorPostRecord {
1983            operation: "auto_post".to_string(),
1984            error: Some(
1985                "Selected provider/model is unavailable. Bug monitor is fail-closed.".to_string(),
1986            ),
1987            ..failed_create.clone()
1988        };
1989        let failed_comment = BugMonitorPostRecord {
1990            operation: "comment".to_string(),
1991            ..failed_create.clone()
1992        };
1993        let posted_create = BugMonitorPostRecord {
1994            status: "posted".to_string(),
1995            ..failed_create.clone()
1996        };
1997        let different_fingerprint = BugMonitorPostRecord {
1998            fingerprint: "other-fingerprint".to_string(),
1999            ..failed_create.clone()
2000        };
2001
2002        assert!(failed_post_suppresses_create(&draft, &failed_create));
2003        assert!(failed_post_suppresses_create(&draft, &failed_auto_post));
2004        assert!(!failed_post_suppresses_create(
2005            &draft,
2006            &failed_preflight_auto_post
2007        ));
2008        assert!(!failed_post_suppresses_create(&draft, &failed_comment));
2009        assert!(!failed_post_suppresses_create(&draft, &posted_create));
2010        assert!(!failed_post_suppresses_create(
2011            &draft,
2012            &different_fingerprint
2013        ));
2014    }
2015
2016    #[test]
2017    fn publish_not_ready_reason_does_not_blame_model_when_github_is_ready() {
2018        let status = crate::BugMonitorStatus {
2019            last_error: Some(
2020                "Selected provider/model is unavailable. Bug monitor is fail-closed.".to_string(),
2021            ),
2022            readiness: crate::BugMonitorReadiness {
2023                repo_valid: true,
2024                mcp_connected: true,
2025                github_read_ready: true,
2026                github_write_ready: true,
2027                selected_model_ready: false,
2028                publish_ready: true,
2029                runtime_ready: false,
2030                ..Default::default()
2031            },
2032            ..Default::default()
2033        };
2034
2035        assert_eq!(
2036            bug_monitor_publish_not_ready_reason(&status),
2037            "Bug Monitor is not ready for GitHub posting"
2038        );
2039    }
2040
2041    fn make_incident_with_excerpt_and_last_error(
2042        excerpt: Vec<String>,
2043        last_error: Option<String>,
2044    ) -> crate::BugMonitorIncidentRecord {
2045        crate::BugMonitorIncidentRecord {
2046            incident_id: "incident-pick".to_string(),
2047            fingerprint: "fp".to_string(),
2048            event_type: "automation_v2.run.failed".to_string(),
2049            status: "queued".to_string(),
2050            repo: "acme/platform".to_string(),
2051            workspace_root: "/tmp/example".to_string(),
2052            title: "Workflow X failed at Y: automation run blocked by upstream node outcome"
2053                .to_string(),
2054            excerpt,
2055            last_error,
2056            ..Default::default()
2057        }
2058    }
2059
2060    #[test]
2061    fn pick_error_message_for_provenance_prefers_excerpt_over_last_error_after_timeout() {
2062        // Mirror the post-timeout state: last_error is the multi-line
2063        // diagnostic; the original failure literal lives on incident.excerpt.
2064        let incident = make_incident_with_excerpt_and_last_error(
2065            vec!["automation run blocked by upstream node outcome".to_string()],
2066            Some(
2067                "triage run X did not reach a terminal status within 300000ms\nelapsed_ms: 301053\n"
2068                    .to_string(),
2069            ),
2070        );
2071        let draft = BugMonitorDraftRecord::default();
2072        let picked = pick_error_message_for_provenance(&draft, Some(&incident));
2073        assert_eq!(
2074            picked.as_deref(),
2075            Some("automation run blocked by upstream node outcome")
2076        );
2077    }
2078
2079    #[test]
2080    fn pick_error_message_for_provenance_falls_back_to_title_suffix() {
2081        let incident = make_incident_with_excerpt_and_last_error(Vec::new(), None);
2082        let draft = BugMonitorDraftRecord::default();
2083        let picked = pick_error_message_for_provenance(&draft, Some(&incident));
2084        assert_eq!(
2085            picked.as_deref(),
2086            Some("automation run blocked by upstream node outcome")
2087        );
2088    }
2089
2090    #[test]
2091    fn extract_error_after_colon_keeps_short_titles_intact() {
2092        // Too short to be a useful suffix → return None so caller falls
2093        // back to the full title.
2094        assert!(extract_error_after_colon("Something: short").is_none());
2095        assert!(extract_error_after_colon("Just one part with no colon").is_none());
2096    }
2097
2098    #[test]
2099    fn extract_error_after_colon_uses_rightmost_split() {
2100        let title = "Workflow auto-v2-foo failed at bar: real error message goes here";
2101        assert_eq!(
2102            extract_error_after_colon(title).as_deref(),
2103            Some("real error message goes here")
2104        );
2105    }
2106
2107    /// Stability regressions: digest must not move on
2108    /// `occurrence_count` (#45/#46), `triage_run_id` (#69-#194 spam,
2109    /// recreated for stale/blocked triages), or `incident.run_id` /
2110    /// `session_id` (redundant with fingerprint). Sanity: still moves
2111    /// on a real fingerprint change.
2112    #[test]
2113    fn compute_evidence_digest_stability_contract() {
2114        let base = BugMonitorDraftRecord {
2115            repo: "frumu-ai/tandem".to_string(),
2116            fingerprint: "abc123".to_string(),
2117            title: Some("Failure".to_string()),
2118            detail: Some("reason: foo".to_string()),
2119            triage_run_id: Some("triage-1".to_string()),
2120            ..Default::default()
2121        };
2122        let baseline = compute_evidence_digest(&base, None);
2123        let mk_inc = |run, sess, count| crate::BugMonitorIncidentRecord {
2124            run_id: Some(String::from(run)),
2125            session_id: Some(String::from(sess)),
2126            occurrence_count: count,
2127            ..Default::default()
2128        };
2129        assert_eq!(
2130            baseline,
2131            compute_evidence_digest(&base, Some(&mk_inc("r", "s", 1)))
2132        );
2133        assert_eq!(
2134            baseline,
2135            compute_evidence_digest(&base, Some(&mk_inc("r", "s", 99)))
2136        );
2137        assert_eq!(
2138            baseline,
2139            compute_evidence_digest(&base, Some(&mk_inc("r2", "s2", 1)))
2140        );
2141        let mut recreated = base.clone();
2142        recreated.triage_run_id = Some("triage-2".to_string());
2143        assert_eq!(baseline, compute_evidence_digest(&recreated, None));
2144        let mut other_fp = base.clone();
2145        other_fp.fingerprint = "fingerprint-B".to_string();
2146        assert_ne!(baseline, compute_evidence_digest(&other_fp, None));
2147    }
2148}