Skip to main content

tandem_server/
bug_monitor_github.rs

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}