1use anyhow::Context;
2use serde_json::{json, Value};
3use tandem_runtime::McpRemoteTool;
4use tandem_types::EngineEvent;
5
6use crate::{
7 now_ms, sha256_hex, truncate_text, AppState, BugMonitorConfig, BugMonitorDraftRecord,
8 BugMonitorPostRecord, ExternalActionRecord,
9};
10
11const BUG_MONITOR_LABEL: &str = "bug-monitor";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PublishMode {
15 Auto,
16 ManualPublish,
17 RecheckOnly,
18}
19
20#[derive(Debug, Clone)]
21pub struct PublishOutcome {
22 pub action: String,
23 pub draft: BugMonitorDraftRecord,
24 pub post: Option<BugMonitorPostRecord>,
25}
26
27pub async fn record_post_failure(
28 state: &AppState,
29 draft: &BugMonitorDraftRecord,
30 incident_id: Option<&str>,
31 operation: &str,
32 evidence_digest: Option<&str>,
33 error: &str,
34) -> anyhow::Result<BugMonitorPostRecord> {
35 let now = now_ms();
36 let post = BugMonitorPostRecord {
37 post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
38 draft_id: draft.draft_id.clone(),
39 incident_id: incident_id.map(|value| value.to_string()),
40 fingerprint: draft.fingerprint.clone(),
41 repo: draft.repo.clone(),
42 operation: operation.to_string(),
43 status: "failed".to_string(),
44 issue_number: draft.issue_number,
45 issue_url: draft.github_issue_url.clone(),
46 comment_id: None,
47 comment_url: draft.github_comment_url.clone(),
48 evidence_digest: evidence_digest.map(|value| value.to_string()),
49 idempotency_key: build_idempotency_key(
50 &draft.repo,
51 &draft.fingerprint,
52 operation,
53 evidence_digest.unwrap_or(""),
54 ),
55 response_excerpt: None,
56 error: Some(truncate_text(error, 500)),
57 created_at_ms: now,
58 updated_at_ms: now,
59 };
60 let post = state.put_bug_monitor_post(post).await?;
61 mirror_bug_monitor_post_as_external_action(state, draft, &post).await;
62 Ok(post)
63}
64
65async fn mirror_bug_monitor_post_as_external_action(
66 state: &AppState,
67 draft: &BugMonitorDraftRecord,
68 post: &BugMonitorPostRecord,
69) {
70 let capability_id = match post.operation.as_str() {
71 "comment_issue" => Some("github.comment_on_issue".to_string()),
72 "create_issue" => Some("github.create_issue".to_string()),
73 _ => None,
74 };
75 let action = ExternalActionRecord {
76 action_id: post.post_id.clone(),
77 operation: post.operation.clone(),
78 status: post.status.clone(),
79 source_kind: Some("bug_monitor".to_string()),
80 source_id: Some(draft.draft_id.clone()),
81 routine_run_id: None,
82 context_run_id: draft.triage_run_id.clone(),
83 capability_id,
84 provider: Some(BUG_MONITOR_LABEL.to_string()),
85 target: Some(draft.repo.clone()),
86 approval_state: Some(if draft.status.eq_ignore_ascii_case("approval_required") {
87 "approval_required".to_string()
88 } else {
89 "executed".to_string()
90 }),
91 idempotency_key: Some(post.idempotency_key.clone()),
92 receipt: Some(json!({
93 "post_id": post.post_id,
94 "draft_id": post.draft_id,
95 "incident_id": post.incident_id,
96 "issue_number": post.issue_number,
97 "issue_url": post.issue_url,
98 "comment_id": post.comment_id,
99 "comment_url": post.comment_url,
100 "response_excerpt": post.response_excerpt,
101 })),
102 error: post.error.clone(),
103 metadata: Some(json!({
104 "repo": post.repo,
105 "fingerprint": post.fingerprint,
106 "evidence_digest": post.evidence_digest,
107 "bug_monitor_operation": post.operation,
108 })),
109 created_at_ms: post.created_at_ms,
110 updated_at_ms: post.updated_at_ms,
111 };
112 if let Err(error) = state.record_external_action(action).await {
113 tracing::warn!(
114 "failed to persist external action mirror for bug monitor post {}: {}",
115 post.post_id,
116 error
117 );
118 }
119}
120
121#[derive(Debug, Clone, Default)]
122struct GithubToolSet {
123 server_name: String,
124 list_issues: String,
125 get_issue: String,
126 create_issue: String,
127 comment_on_issue: String,
128}
129
130#[derive(Debug, Clone, Default)]
131struct GithubIssue {
132 number: u64,
133 title: String,
134 body: String,
135 state: String,
136 html_url: Option<String>,
137}
138
139#[derive(Debug, Clone, Default)]
140struct GithubComment {
141 id: Option<String>,
142 html_url: Option<String>,
143}
144
145pub async fn publish_draft(
146 state: &AppState,
147 draft_id: &str,
148 incident_id: Option<&str>,
149 mode: PublishMode,
150) -> anyhow::Result<PublishOutcome> {
151 let status = state.bug_monitor_status().await;
152 let config = status.config.clone();
153 if !config.enabled {
154 anyhow::bail!("Bug Monitor is disabled");
155 }
156 if config.paused && mode == PublishMode::Auto {
157 anyhow::bail!("Bug Monitor is paused");
158 }
159 if !status.readiness.runtime_ready && mode == PublishMode::Auto {
160 anyhow::bail!(
161 "{}",
162 status
163 .last_error
164 .clone()
165 .unwrap_or_else(|| "Bug Monitor is not ready for GitHub posting".to_string())
166 );
167 }
168 let mut draft = state
169 .get_bug_monitor_draft(draft_id)
170 .await
171 .ok_or_else(|| anyhow::anyhow!("Bug Monitor draft not found"))?;
172 if draft.status.eq_ignore_ascii_case("denied") {
173 anyhow::bail!("Bug Monitor draft has been denied");
174 }
175 if mode == PublishMode::Auto
176 && config.require_approval_for_new_issues
177 && draft.status.eq_ignore_ascii_case("approval_required")
178 {
179 return Ok(PublishOutcome {
180 action: "approval_required".to_string(),
181 draft,
182 post: None,
183 });
184 }
185
186 let tools = resolve_github_tool_set(state, &config)
187 .await
188 .context("resolve GitHub MCP tools for Bug Monitor")?;
189 let incident = match incident_id {
190 Some(id) => state.get_bug_monitor_incident(id).await,
191 None => None,
192 };
193 let evidence_digest = compute_evidence_digest(&draft, incident.as_ref());
194 draft.evidence_digest = Some(evidence_digest.clone());
195 if mode != PublishMode::RecheckOnly {
196 if let Some(existing) =
197 successful_post_for_draft(state, &draft.draft_id, Some(&evidence_digest)).await
198 {
199 draft.github_status = Some("duplicate_skipped".to_string());
200 draft.issue_number = existing.issue_number;
201 draft.github_issue_url = existing.issue_url.clone();
202 draft.github_comment_url = existing.comment_url.clone();
203 draft.github_posted_at_ms = Some(existing.updated_at_ms);
204 draft.last_post_error = None;
205 mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
206 let draft = state.put_bug_monitor_draft(draft).await?;
207 return Ok(PublishOutcome {
208 action: "skip_duplicate".to_string(),
209 draft,
210 post: Some(existing),
211 });
212 }
213 }
214 let issue_draft = if mode == PublishMode::RecheckOnly {
215 None
216 } else if draft.triage_run_id.is_none() {
217 if mode == PublishMode::ManualPublish {
218 anyhow::bail!("Bug Monitor draft needs a triage run before GitHub publish");
219 }
220 None
221 } else if mode == PublishMode::ManualPublish {
222 Some(
223 crate::http::bug_monitor::ensure_bug_monitor_issue_draft(
224 state.clone(),
225 &draft.draft_id,
226 false,
227 )
228 .await
229 .context("generate Bug Monitor issue draft")?,
230 )
231 } else {
232 crate::http::bug_monitor::load_bug_monitor_issue_draft_artifact(
233 state,
234 draft.triage_run_id.as_deref().unwrap_or_default(),
235 )
236 .await
237 };
238 if issue_draft.is_none() && draft.triage_run_id.is_some() && mode == PublishMode::Auto {
239 draft.github_status = Some("triage_pending".to_string());
240 let draft = state.put_bug_monitor_draft(draft).await?;
241 return Ok(PublishOutcome {
242 action: "triage_pending".to_string(),
243 draft,
244 post: None,
245 });
246 }
247
248 let owner_repo = split_owner_repo(&draft.repo)?;
249 let matched_issue = find_matching_issue(state, &tools, &owner_repo, &draft)
250 .await
251 .context("match existing GitHub issue for Bug Monitor draft")?;
252
253 match matched_issue {
254 Some(issue) if issue.state.eq_ignore_ascii_case("open") => {
255 draft.matched_issue_number = Some(issue.number);
256 draft.matched_issue_state = Some(issue.state.clone());
257 if mode == PublishMode::RecheckOnly {
258 let draft = state.put_bug_monitor_draft(draft).await?;
259 return Ok(PublishOutcome {
260 action: "matched_open".to_string(),
261 draft,
262 post: None,
263 });
264 }
265 if !config.auto_comment_on_matched_open_issues && mode == PublishMode::Auto {
266 draft.github_status = Some("draft_ready".to_string());
267 let draft = state.put_bug_monitor_draft(draft).await?;
268 return Ok(PublishOutcome {
269 action: "matched_open_no_comment".to_string(),
270 draft,
271 post: None,
272 });
273 }
274 let idempotency_key = build_idempotency_key(
275 &draft.repo,
276 &draft.fingerprint,
277 "comment_issue",
278 &evidence_digest,
279 );
280 if let Some(existing) = successful_post_by_idempotency(state, &idempotency_key).await {
281 draft.github_status = Some("duplicate_skipped".to_string());
282 draft.issue_number = existing.issue_number;
283 draft.github_issue_url = existing.issue_url.clone();
284 draft.github_comment_url = existing.comment_url.clone();
285 draft.github_posted_at_ms = Some(existing.updated_at_ms);
286 draft.last_post_error = None;
287 mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
288 let draft = state.put_bug_monitor_draft(draft).await?;
289 return Ok(PublishOutcome {
290 action: "skip_duplicate".to_string(),
291 draft,
292 post: Some(existing),
293 });
294 }
295 let body = build_comment_body(
296 &draft,
297 incident.as_ref(),
298 issue.number,
299 &evidence_digest,
300 issue_draft.as_ref(),
301 );
302 let result = call_add_issue_comment(state, &tools, &owner_repo, issue.number, &body)
303 .await
304 .context("post Bug Monitor comment to GitHub")?;
305 let post = BugMonitorPostRecord {
306 post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
307 draft_id: draft.draft_id.clone(),
308 incident_id: incident.as_ref().map(|row| row.incident_id.clone()),
309 fingerprint: draft.fingerprint.clone(),
310 repo: draft.repo.clone(),
311 operation: "comment_issue".to_string(),
312 status: "posted".to_string(),
313 issue_number: Some(issue.number),
314 issue_url: issue.html_url.clone(),
315 comment_id: result.id.clone(),
316 comment_url: result.html_url.clone(),
317 evidence_digest: Some(evidence_digest.clone()),
318 idempotency_key,
319 response_excerpt: Some(truncate_text(&body, 400)),
320 error: None,
321 created_at_ms: now_ms(),
322 updated_at_ms: now_ms(),
323 };
324 let post = state.put_bug_monitor_post(post).await?;
325 mirror_bug_monitor_post_as_external_action(state, &draft, &post).await;
326 draft.status = "github_comment_posted".to_string();
327 draft.github_status = Some("github_comment_posted".to_string());
328 draft.github_issue_url = issue.html_url.clone();
329 draft.github_comment_url = result.html_url.clone();
330 draft.github_posted_at_ms = Some(post.updated_at_ms);
331 draft.issue_number = Some(issue.number);
332 draft.last_post_error = None;
333 let draft = state.put_bug_monitor_draft(draft).await?;
334 state
335 .update_bug_monitor_runtime_status(|runtime| {
336 runtime.last_post_result = Some(format!("commented issue #{}", issue.number));
337 })
338 .await;
339 state.event_bus.publish(EngineEvent::new(
340 "bug_monitor.github.comment_posted",
341 json!({
342 "draft_id": draft.draft_id,
343 "issue_number": issue.number,
344 "repo": draft.repo,
345 }),
346 ));
347 Ok(PublishOutcome {
348 action: "comment_issue".to_string(),
349 draft,
350 post: Some(post),
351 })
352 }
353 Some(issue) => {
354 draft.matched_issue_number = Some(issue.number);
355 draft.matched_issue_state = Some(issue.state.clone());
356 if mode == PublishMode::RecheckOnly {
357 let draft = state.put_bug_monitor_draft(draft).await?;
358 return Ok(PublishOutcome {
359 action: "matched_closed".to_string(),
360 draft,
361 post: None,
362 });
363 }
364 create_issue_from_draft(
365 state,
366 &tools,
367 &config,
368 draft,
369 incident.as_ref(),
370 Some(&issue),
371 &evidence_digest,
372 issue_draft.as_ref(),
373 )
374 .await
375 }
376 None => {
377 if mode == PublishMode::RecheckOnly {
378 let draft = state.put_bug_monitor_draft(draft).await?;
379 return Ok(PublishOutcome {
380 action: "no_match".to_string(),
381 draft,
382 post: None,
383 });
384 }
385 create_issue_from_draft(
386 state,
387 &tools,
388 &config,
389 draft,
390 incident.as_ref(),
391 None,
392 &evidence_digest,
393 issue_draft.as_ref(),
394 )
395 .await
396 }
397 }
398}
399
400async fn create_issue_from_draft(
401 state: &AppState,
402 tools: &GithubToolSet,
403 config: &BugMonitorConfig,
404 mut draft: BugMonitorDraftRecord,
405 incident: Option<&crate::BugMonitorIncidentRecord>,
406 matched_closed_issue: Option<&GithubIssue>,
407 evidence_digest: &str,
408 issue_draft: Option<&Value>,
409) -> anyhow::Result<PublishOutcome> {
410 if config.require_approval_for_new_issues && !draft.status.eq_ignore_ascii_case("draft_ready") {
411 draft.status = "approval_required".to_string();
412 draft.github_status = Some("approval_required".to_string());
413 let draft = state.put_bug_monitor_draft(draft).await?;
414 return Ok(PublishOutcome {
415 action: "approval_required".to_string(),
416 draft,
417 post: None,
418 });
419 }
420 if !config.auto_create_new_issues && draft.status.eq_ignore_ascii_case("draft_ready") {
421 let draft = state.put_bug_monitor_draft(draft).await?;
422 return Ok(PublishOutcome {
423 action: "draft_ready".to_string(),
424 draft,
425 post: None,
426 });
427 }
428 let idempotency_key = build_idempotency_key(
429 &draft.repo,
430 &draft.fingerprint,
431 "create_issue",
432 evidence_digest,
433 );
434 if let Some(existing) = successful_post_by_idempotency(state, &idempotency_key).await {
435 draft.status = "github_issue_created".to_string();
436 draft.github_status = Some("github_issue_created".to_string());
437 draft.issue_number = existing.issue_number;
438 draft.github_issue_url = existing.issue_url.clone();
439 draft.github_posted_at_ms = Some(existing.updated_at_ms);
440 draft.last_post_error = None;
441 mirror_bug_monitor_post_as_external_action(state, &draft, &existing).await;
442 let draft = state.put_bug_monitor_draft(draft).await?;
443 return Ok(PublishOutcome {
444 action: "skip_duplicate".to_string(),
445 draft,
446 post: Some(existing),
447 });
448 }
449
450 let owner_repo = split_owner_repo(&draft.repo)?;
451 let title = issue_draft
452 .and_then(|row| row.get("suggested_title"))
453 .and_then(Value::as_str)
454 .filter(|value| !value.trim().is_empty())
455 .unwrap_or_else(|| draft.title.as_deref().unwrap_or("Bug Monitor issue"));
456 let body = issue_draft
457 .and_then(|row| row.get("rendered_body"))
458 .and_then(Value::as_str)
459 .filter(|value| !value.trim().is_empty())
460 .map(ToString::to_string)
461 .unwrap_or_else(|| {
462 build_issue_body(&draft, incident, matched_closed_issue, evidence_digest)
463 });
464 let created = call_create_issue(state, tools, &owner_repo, title, &body)
465 .await
466 .context("create Bug Monitor issue on GitHub")?;
467 let post = BugMonitorPostRecord {
468 post_id: format!("failure-post-{}", uuid::Uuid::new_v4().simple()),
469 draft_id: draft.draft_id.clone(),
470 incident_id: incident.map(|row| row.incident_id.clone()),
471 fingerprint: draft.fingerprint.clone(),
472 repo: draft.repo.clone(),
473 operation: "create_issue".to_string(),
474 status: "posted".to_string(),
475 issue_number: Some(created.number),
476 issue_url: created.html_url.clone(),
477 comment_id: None,
478 comment_url: None,
479 evidence_digest: Some(evidence_digest.to_string()),
480 idempotency_key,
481 response_excerpt: Some(truncate_text(&body, 400)),
482 error: None,
483 created_at_ms: now_ms(),
484 updated_at_ms: now_ms(),
485 };
486 let post = state.put_bug_monitor_post(post).await?;
487 mirror_bug_monitor_post_as_external_action(state, &draft, &post).await;
488 draft.status = "github_issue_created".to_string();
489 draft.github_status = Some("github_issue_created".to_string());
490 draft.github_issue_url = created.html_url.clone();
491 draft.github_posted_at_ms = Some(post.updated_at_ms);
492 draft.issue_number = Some(created.number);
493 draft.last_post_error = None;
494 let draft = state.put_bug_monitor_draft(draft).await?;
495 state
496 .update_bug_monitor_runtime_status(|runtime| {
497 runtime.last_post_result = Some(format!("created issue #{}", created.number));
498 })
499 .await;
500 state.event_bus.publish(EngineEvent::new(
501 "bug_monitor.github.issue_created",
502 json!({
503 "draft_id": draft.draft_id,
504 "issue_number": created.number,
505 "repo": draft.repo,
506 }),
507 ));
508 Ok(PublishOutcome {
509 action: "create_issue".to_string(),
510 draft,
511 post: Some(post),
512 })
513}
514
515async fn resolve_github_tool_set(
516 state: &AppState,
517 config: &BugMonitorConfig,
518) -> anyhow::Result<GithubToolSet> {
519 let server_name = config
520 .mcp_server
521 .as_ref()
522 .filter(|value| !value.trim().is_empty())
523 .ok_or_else(|| anyhow::anyhow!("Bug Monitor MCP server is not configured"))?
524 .to_string();
525 let mut server_tools = state.mcp.server_tools(&server_name).await;
526 if server_tools.is_empty() && state.mcp.connect(&server_name).await {
527 server_tools = state.mcp.server_tools(&server_name).await;
528 }
529 if server_tools.is_empty() {
530 anyhow::bail!("no MCP tools were discovered for selected Bug Monitor server");
531 }
532 let discovered = state
533 .capability_resolver
534 .discover_from_runtime(server_tools.clone(), Vec::new())
535 .await;
536 let mut resolved = state
537 .capability_resolver
538 .resolve(
539 crate::capability_resolver::CapabilityResolveInput {
540 workflow_id: Some("bug-monitor-github".to_string()),
541 required_capabilities: vec![
542 "github.list_issues".to_string(),
543 "github.get_issue".to_string(),
544 "github.create_issue".to_string(),
545 "github.comment_on_issue".to_string(),
546 ],
547 optional_capabilities: Vec::new(),
548 provider_preference: vec!["mcp".to_string()],
549 available_tools: discovered,
550 },
551 Vec::new(),
552 )
553 .await?;
554 if !resolved.missing_required.is_empty() {
555 let _ = state.capability_resolver.refresh_builtin_bindings().await;
556 let discovered = state
557 .capability_resolver
558 .discover_from_runtime(server_tools.clone(), Vec::new())
559 .await;
560 resolved = state
561 .capability_resolver
562 .resolve(
563 crate::capability_resolver::CapabilityResolveInput {
564 workflow_id: Some("bug-monitor-github".to_string()),
565 required_capabilities: vec![
566 "github.list_issues".to_string(),
567 "github.get_issue".to_string(),
568 "github.create_issue".to_string(),
569 "github.comment_on_issue".to_string(),
570 ],
571 optional_capabilities: Vec::new(),
572 provider_preference: vec!["mcp".to_string()],
573 available_tools: discovered,
574 },
575 Vec::new(),
576 )
577 .await?;
578 }
579 let tool_name = |capability_id: &str| -> anyhow::Result<String> {
580 let namespaced = resolved
581 .resolved
582 .iter()
583 .find(|row| row.capability_id == capability_id)
584 .map(|row| row.tool_name.clone())
585 .ok_or_else(|| anyhow::anyhow!("missing resolved tool for {capability_id}"))?;
586 map_namespaced_to_raw_tool(&server_tools, &namespaced)
587 };
588 let direct_tool_name_fallback = |candidates: &[&str]| -> Option<String> {
589 server_tools
590 .iter()
591 .find(|row| {
592 candidates.iter().any(|candidate| {
593 row.tool_name.eq_ignore_ascii_case(candidate)
594 || row.namespaced_name.eq_ignore_ascii_case(candidate)
595 })
596 })
597 .map(|row| row.tool_name.clone())
598 };
599 let list_issues = tool_name("github.list_issues").or_else(|_| {
600 direct_tool_name_fallback(&["list_repository_issues", "mcp.github.list_issues"])
601 .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.list_issues"))
602 })?;
603 let get_issue = tool_name("github.get_issue").or_else(|_| {
604 direct_tool_name_fallback(&["get_issue", "mcp.github.get_issue"])
605 .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.get_issue"))
606 })?;
607 let create_issue = tool_name("github.create_issue").or_else(|_| {
608 direct_tool_name_fallback(&["mcp.github.create_issue", "create_issue"])
609 .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.create_issue"))
610 })?;
611 let comment_on_issue = tool_name("github.comment_on_issue").or_else(|_| {
612 direct_tool_name_fallback(&[
613 "mcp.github.create_issue_comment",
614 "create_issue_comment",
615 "github.comment_on_issue",
616 ])
617 .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.comment_on_issue"))
618 })?;
619 Ok(GithubToolSet {
620 server_name,
621 list_issues,
622 get_issue,
623 create_issue,
624 comment_on_issue,
625 })
626}
627
628fn map_namespaced_to_raw_tool(
629 tools: &[McpRemoteTool],
630 namespaced_name: &str,
631) -> anyhow::Result<String> {
632 tools
633 .iter()
634 .find(|row| row.namespaced_name == namespaced_name)
635 .map(|row| row.tool_name.clone())
636 .ok_or_else(|| anyhow::anyhow!("failed to map MCP tool `{namespaced_name}` to raw tool"))
637}
638
639async fn find_matching_issue(
640 state: &AppState,
641 tools: &GithubToolSet,
642 owner_repo: &(&str, &str),
643 draft: &BugMonitorDraftRecord,
644) -> anyhow::Result<Option<GithubIssue>> {
645 let mut issues = call_list_issues(state, tools, owner_repo).await?;
646 if let Some(existing_number) = draft.issue_number {
647 if let Some(existing) = issues
648 .iter()
649 .find(|row| row.number == existing_number)
650 .cloned()
651 {
652 return Ok(Some(existing));
653 }
654 if let Ok(issue) = call_get_issue(state, tools, owner_repo, existing_number).await {
655 return Ok(Some(issue));
656 }
657 }
658 let marker = fingerprint_marker(&draft.fingerprint);
659 issues.sort_by(|a, b| b.number.cmp(&a.number));
660 let exact_marker = issues
661 .iter()
662 .find(|issue| issue.body.contains(&marker))
663 .cloned();
664 if exact_marker.is_some() {
665 return Ok(exact_marker);
666 }
667 let normalized_title = draft
668 .title
669 .as_deref()
670 .map(|value| value.trim().to_ascii_lowercase())
671 .unwrap_or_default();
672 Ok(issues.into_iter().find(|issue| {
673 issue.title.trim().eq_ignore_ascii_case(&normalized_title)
674 || issue.body.contains(&draft.fingerprint)
675 }))
676}
677
678async fn successful_post_by_idempotency(
679 state: &AppState,
680 idempotency_key: &str,
681) -> Option<BugMonitorPostRecord> {
682 state
683 .bug_monitor_posts
684 .read()
685 .await
686 .values()
687 .find(|row| row.idempotency_key == idempotency_key && row.status == "posted")
688 .cloned()
689}
690
691async fn successful_post_for_draft(
692 state: &AppState,
693 draft_id: &str,
694 evidence_digest: Option<&str>,
695) -> Option<BugMonitorPostRecord> {
696 let mut rows = state.list_bug_monitor_posts(200).await;
697 rows.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
698 rows.into_iter().find(|row| {
699 row.draft_id == draft_id
700 && row.status == "posted"
701 && match evidence_digest {
702 Some(expected) => row.evidence_digest.as_deref() == Some(expected),
703 None => true,
704 }
705 })
706}
707
708fn compute_evidence_digest(
709 draft: &BugMonitorDraftRecord,
710 incident: Option<&crate::BugMonitorIncidentRecord>,
711) -> String {
712 sha256_hex(&[
713 draft.repo.as_str(),
714 draft.fingerprint.as_str(),
715 draft.title.as_deref().unwrap_or(""),
716 draft.detail.as_deref().unwrap_or(""),
717 draft.triage_run_id.as_deref().unwrap_or(""),
718 incident
719 .and_then(|row| row.session_id.as_deref())
720 .unwrap_or(""),
721 incident.and_then(|row| row.run_id.as_deref()).unwrap_or(""),
722 incident
723 .map(|row| row.occurrence_count.to_string())
724 .unwrap_or_default()
725 .as_str(),
726 ])
727}
728
729fn build_idempotency_key(repo: &str, fingerprint: &str, operation: &str, digest: &str) -> String {
730 sha256_hex(&[repo, fingerprint, operation, digest])
731}
732
733fn build_issue_body(
734 draft: &BugMonitorDraftRecord,
735 incident: Option<&crate::BugMonitorIncidentRecord>,
736 matched_closed_issue: Option<&GithubIssue>,
737 evidence_digest: &str,
738) -> String {
739 let mut lines = Vec::new();
740 if let Some(detail) = draft.detail.as_deref() {
741 lines.push(detail.to_string());
742 }
743 if let Some(run_id) = draft.triage_run_id.as_deref() {
744 if !lines.is_empty() {
745 lines.push(String::new());
746 }
747 lines.push(format!("triage_run_id: {run_id}"));
748 }
749 if let Some(issue) = matched_closed_issue {
750 lines.push(format!(
751 "previous_closed_issue: #{} ({})",
752 issue.number, issue.state
753 ));
754 }
755 if let Some(incident) = incident {
756 lines.push(format!("incident_id: {}", incident.incident_id));
757 if let Some(event_type) = Some(incident.event_type.as_str()) {
758 lines.push(format!("event_type: {event_type}"));
759 }
760 if !incident.workspace_root.trim().is_empty() {
761 lines.push(format!("local_directory: {}", incident.workspace_root));
762 }
763 }
764 lines.push(String::new());
765 lines.push(fingerprint_marker(&draft.fingerprint));
766 lines.push(evidence_marker(evidence_digest));
767 lines.join("\n")
768}
769
770fn build_comment_body(
771 draft: &BugMonitorDraftRecord,
772 incident: Option<&crate::BugMonitorIncidentRecord>,
773 issue_number: u64,
774 evidence_digest: &str,
775 issue_draft: Option<&Value>,
776) -> String {
777 let mut lines = vec![format!(
778 "New Bug Monitor evidence detected for #{issue_number}."
779 )];
780 if let Some(summary) = issue_draft
781 .and_then(|row| row.get("what_happened"))
782 .and_then(Value::as_str)
783 .filter(|value| !value.trim().is_empty())
784 {
785 lines.push(String::new());
786 lines.push(truncate_text(summary, 1_500));
787 } else if let Some(detail) = draft.detail.as_deref() {
788 lines.push(String::new());
789 lines.push(truncate_text(detail, 1_500));
790 }
791 if let Some(logs) = issue_draft
792 .and_then(|row| row.get("logs"))
793 .and_then(Value::as_array)
794 .filter(|rows| !rows.is_empty())
795 {
796 lines.push(String::new());
797 lines.push("logs:".to_string());
798 for line in logs.iter().filter_map(Value::as_str).take(6) {
799 lines.push(format!(" {line}"));
800 }
801 }
802 if let Some(incident) = incident {
803 lines.push(String::new());
804 lines.push(format!("incident_id: {}", incident.incident_id));
805 if let Some(run_id) = incident.run_id.as_deref() {
806 lines.push(format!("run_id: {run_id}"));
807 }
808 if let Some(session_id) = incident.session_id.as_deref() {
809 lines.push(format!("session_id: {session_id}"));
810 }
811 }
812 if let Some(run_id) = draft.triage_run_id.as_deref() {
813 lines.push(format!("triage_run_id: {run_id}"));
814 }
815 lines.push(String::new());
816 lines.push(evidence_marker(evidence_digest));
817 lines.join("\n")
818}
819
820fn fingerprint_marker(fingerprint: &str) -> String {
821 format!("<!-- tandem:fingerprint:v1:{fingerprint} -->")
822}
823
824fn evidence_marker(digest: &str) -> String {
825 format!("<!-- tandem:evidence:v1:{digest} -->")
826}
827
828fn split_owner_repo(repo: &str) -> anyhow::Result<(&str, &str)> {
829 let mut parts = repo.split('/');
830 let owner = parts
831 .next()
832 .filter(|value| !value.trim().is_empty())
833 .ok_or_else(|| anyhow::anyhow!("invalid owner/repo value"))?;
834 let repo_name = parts
835 .next()
836 .filter(|value| !value.trim().is_empty())
837 .ok_or_else(|| anyhow::anyhow!("invalid owner/repo value"))?;
838 if parts.next().is_some() {
839 anyhow::bail!("invalid owner/repo value");
840 }
841 Ok((owner, repo_name))
842}
843
844async fn call_list_issues(
845 state: &AppState,
846 tools: &GithubToolSet,
847 (owner, repo): &(&str, &str),
848) -> anyhow::Result<Vec<GithubIssue>> {
849 let result = state
850 .mcp
851 .call_tool(
852 &tools.server_name,
853 &tools.list_issues,
854 json!({
855 "owner": owner,
856 "repo": repo,
857 "state": "all",
858 "perPage": 100
859 }),
860 )
861 .await
862 .map_err(anyhow::Error::msg)?;
863 Ok(extract_issues_from_tool_result(&result))
864}
865
866async fn call_get_issue(
867 state: &AppState,
868 tools: &GithubToolSet,
869 (owner, repo): &(&str, &str),
870 issue_number: u64,
871) -> anyhow::Result<GithubIssue> {
872 let result = state
873 .mcp
874 .call_tool(
875 &tools.server_name,
876 &tools.get_issue,
877 json!({
878 "owner": owner,
879 "repo": repo,
880 "issue_number": issue_number
881 }),
882 )
883 .await
884 .map_err(anyhow::Error::msg)?;
885 extract_issues_from_tool_result(&result)
886 .into_iter()
887 .find(|issue| issue.number == issue_number)
888 .ok_or_else(|| anyhow::anyhow!("GitHub issue #{issue_number} was not returned"))
889}
890
891async fn call_create_issue(
892 state: &AppState,
893 tools: &GithubToolSet,
894 (owner, repo): &(&str, &str),
895 title: &str,
896 body: &str,
897) -> anyhow::Result<GithubIssue> {
898 let preferred = json!({
899 "method": "create",
900 "owner": owner,
901 "repo": repo,
902 "title": title,
903 "body": body,
904 "labels": [BUG_MONITOR_LABEL],
905 });
906 let fallback = json!({
907 "owner": owner,
908 "repo": repo,
909 "title": title,
910 "body": body,
911 "labels": [BUG_MONITOR_LABEL],
912 });
913 let first = state
914 .mcp
915 .call_tool(&tools.server_name, &tools.create_issue, preferred)
916 .await;
917 let result = match first {
918 Ok(result) => result,
919 Err(_) => state
920 .mcp
921 .call_tool(&tools.server_name, &tools.create_issue, fallback)
922 .await
923 .map_err(anyhow::Error::msg)?,
924 };
925 extract_issues_from_tool_result(&result)
926 .into_iter()
927 .next()
928 .ok_or_else(|| anyhow::anyhow!("GitHub issue creation returned no issue payload"))
929}
930
931async fn call_add_issue_comment(
932 state: &AppState,
933 tools: &GithubToolSet,
934 (owner, repo): &(&str, &str),
935 issue_number: u64,
936 body: &str,
937) -> anyhow::Result<GithubComment> {
938 let result = state
939 .mcp
940 .call_tool(
941 &tools.server_name,
942 &tools.comment_on_issue,
943 json!({
944 "owner": owner,
945 "repo": repo,
946 "issue_number": issue_number,
947 "body": body
948 }),
949 )
950 .await
951 .map_err(anyhow::Error::msg)?;
952 extract_comments_from_tool_result(&result)
953 .into_iter()
954 .next()
955 .ok_or_else(|| anyhow::anyhow!("GitHub comment creation returned no comment payload"))
956}
957
958fn extract_issues_from_tool_result(result: &tandem_types::ToolResult) -> Vec<GithubIssue> {
959 let mut out = Vec::new();
960 for candidate in tool_result_values(result) {
961 collect_issues(&candidate, &mut out);
962 }
963 dedupe_issues(out)
964}
965
966fn extract_comments_from_tool_result(result: &tandem_types::ToolResult) -> Vec<GithubComment> {
967 let mut out = Vec::new();
968 for candidate in tool_result_values(result) {
969 collect_comments(&candidate, &mut out);
970 }
971 dedupe_comments(out)
972}
973
974fn tool_result_values(result: &tandem_types::ToolResult) -> Vec<Value> {
975 let mut values = Vec::new();
976 if let Some(value) = result.metadata.get("result") {
977 values.push(value.clone());
978 }
979 if let Ok(parsed) = serde_json::from_str::<Value>(&result.output) {
980 values.push(parsed);
981 }
982 values
983}
984
985fn collect_issues(value: &Value, out: &mut Vec<GithubIssue>) {
986 match value {
987 Value::Object(map) => {
988 let issue_number = map
989 .get("number")
990 .or_else(|| map.get("issue_number"))
991 .and_then(Value::as_u64);
992 let title = map
993 .get("title")
994 .and_then(Value::as_str)
995 .unwrap_or_default()
996 .to_string();
997 let body = map
998 .get("body")
999 .and_then(Value::as_str)
1000 .unwrap_or_default()
1001 .to_string();
1002 let state = map
1003 .get("state")
1004 .and_then(Value::as_str)
1005 .unwrap_or_default()
1006 .to_string();
1007 let html_url = map
1008 .get("html_url")
1009 .or_else(|| map.get("url"))
1010 .and_then(Value::as_str)
1011 .map(|value| value.to_string());
1012 if let Some(number) = issue_number {
1013 if !title.is_empty() || !body.is_empty() || !state.is_empty() {
1014 out.push(GithubIssue {
1015 number,
1016 title,
1017 body,
1018 state,
1019 html_url,
1020 });
1021 }
1022 }
1023 for nested in map.values() {
1024 collect_issues(nested, out);
1025 }
1026 }
1027 Value::Array(rows) => {
1028 for row in rows {
1029 collect_issues(row, out);
1030 }
1031 }
1032 _ => {}
1033 }
1034}
1035
1036fn collect_comments(value: &Value, out: &mut Vec<GithubComment>) {
1037 match value {
1038 Value::Object(map) => {
1039 if map.contains_key("id") && (map.contains_key("html_url") || map.contains_key("url")) {
1040 out.push(GithubComment {
1041 id: map.get("id").map(|value| {
1042 value
1043 .as_str()
1044 .map(|row| row.to_string())
1045 .unwrap_or_else(|| value.to_string())
1046 }),
1047 html_url: map
1048 .get("html_url")
1049 .or_else(|| map.get("url"))
1050 .and_then(Value::as_str)
1051 .map(|value| value.to_string()),
1052 });
1053 }
1054 for nested in map.values() {
1055 collect_comments(nested, out);
1056 }
1057 }
1058 Value::Array(rows) => {
1059 for row in rows {
1060 collect_comments(row, out);
1061 }
1062 }
1063 _ => {}
1064 }
1065}
1066
1067fn dedupe_issues(rows: Vec<GithubIssue>) -> Vec<GithubIssue> {
1068 let mut out = Vec::new();
1069 let mut seen = std::collections::HashSet::new();
1070 for row in rows {
1071 if seen.insert(row.number) {
1072 out.push(row);
1073 }
1074 }
1075 out
1076}
1077
1078fn dedupe_comments(rows: Vec<GithubComment>) -> Vec<GithubComment> {
1079 let mut out = Vec::new();
1080 let mut seen = std::collections::HashSet::new();
1081 for row in rows {
1082 let key = row.id.clone().or(row.html_url.clone()).unwrap_or_default();
1083 if !key.is_empty() && seen.insert(key) {
1084 out.push(row);
1085 }
1086 }
1087 out
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use tandem_types::ToolResult;
1094
1095 #[test]
1096 fn build_issue_body_includes_hidden_markers() {
1097 let draft = BugMonitorDraftRecord {
1098 draft_id: "draft-1".to_string(),
1099 fingerprint: "abc123".to_string(),
1100 repo: "acme/platform".to_string(),
1101 status: "draft_ready".to_string(),
1102 created_at_ms: 1,
1103 triage_run_id: Some("triage-1".to_string()),
1104 issue_number: None,
1105 title: Some("session.error detected".to_string()),
1106 detail: Some("summary".to_string()),
1107 ..BugMonitorDraftRecord::default()
1108 };
1109 let body = build_issue_body(&draft, None, None, "digest-1");
1110 assert!(body.contains("<!-- tandem:fingerprint:v1:abc123 -->"));
1111 assert!(body.contains("<!-- tandem:evidence:v1:digest-1 -->"));
1112 assert!(body.contains("triage_run_id: triage-1"));
1113 }
1114
1115 #[test]
1116 fn extract_issues_from_official_github_mcp_result() {
1117 let result = ToolResult {
1118 output: String::new(),
1119 metadata: json!({
1120 "result": {
1121 "issues": [
1122 {
1123 "number": 42,
1124 "title": "Bug Monitor issue",
1125 "body": "details\n<!-- tandem:fingerprint:v1:deadbeef -->",
1126 "state": "open",
1127 "html_url": "https://github.com/acme/platform/issues/42"
1128 }
1129 ]
1130 }
1131 }),
1132 };
1133 let issues = extract_issues_from_tool_result(&result);
1134 assert_eq!(issues.len(), 1);
1135 assert_eq!(issues[0].number, 42);
1136 assert_eq!(issues[0].state, "open");
1137 assert!(issues[0].body.contains("deadbeef"));
1138 }
1139}