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#[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
883fn 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 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 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
1492async 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(§ion);
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(§ion);
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 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 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
1712fn 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 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 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 #[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}