Skip to main content

yapi_core/
bug_report.rs

1//! Bug reports for `/bug`: what a report holds, its redaction, the zip
2//! archive, the summary the current model writes and the GitHub issue.
3//!
4//! A port of `core/bug-report.ts` in pi `v1.0.0`. pi uploads the report to
5//! its own service. yapi always writes the zip and can post a public issue
6//! with the GitHub CLI.
7
8use std::ffi::OsStr;
9use std::io::{self, Write};
10use std::path::Path;
11use std::process::{Command, Stdio};
12
13use flate2::Compression;
14use flate2::write::DeflateEncoder;
15use serde_json::{Value, json};
16use yapi_ai::registry::ModelRegistry;
17use yapi_types::message::{Message, StopReason, blocks_text};
18use yapi_types::model::Model;
19use yapi_types::session::FileEntry;
20
21use crate::agent_session::AgentSession;
22use crate::compaction::{Summarizer, check_summary, estimate_tokens, serialize_conversation};
23use crate::messages::convert_to_llm;
24use crate::session::SessionManager;
25use crate::settings::Scope;
26use crate::time::{now_iso, now_ms, uuid_v7};
27
28/// The type of the session entry that records a report.
29pub const ENTRY_TYPE: &str = "yapi.bug-report";
30/// The repository reports go to.
31pub const REPO: &str = "SkymanOne/yapi";
32/// Where a report can be filed by hand.
33pub const ISSUES_URL: &str = "https://github.com/SkymanOne/yapi/issues";
34
35const SCHEMA_VERSION: u32 = 1;
36const REDACTED: &str = "<redacted>";
37/// Words that make a key name a credential, besides an API key.
38const SENSITIVE_WORDS: [&str; 7] = [
39    "secret",
40    "token",
41    "password",
42    "passwd",
43    "credential",
44    "authorization",
45    "cookie",
46];
47/// GitHub's limit on an issue body, in characters.
48const BODY_LIMIT: usize = 65_536;
49/// GitHub's limit on an issue title, in characters.
50const TITLE_LIMIT: usize = 256;
51
52/// pi's `isSensitiveKey`: whether `key`, split into words at `-`, `_` and
53/// camel case, holds an API key, secret, token, password, credential,
54/// authorization or cookie word.
55fn is_sensitive_key(key: &str) -> bool {
56    let mut split = String::new();
57    let mut previous: Option<char> = None;
58    for c in key.chars() {
59        if c.is_ascii_uppercase()
60            && previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
61        {
62            split.push('_');
63        }
64        split.push(c.to_ascii_lowercase());
65        previous = Some(c);
66    }
67    let words: Vec<&str> = split.split(['-', '_']).collect();
68    words.iter().enumerate().any(|(index, word)| {
69        SENSITIVE_WORDS.contains(word)
70            || *word == "apikey"
71            || (*word == "api" && words.get(index + 1) == Some(&"key"))
72    })
73}
74
75/// The length of the scheme and colon `text` starts with, if it does: a
76/// letter, then letters, digits, `+`, `.` or `-`.
77fn scheme_len(text: &str) -> Option<usize> {
78    let end = text.find(':')?;
79    let mut chars = text[..end].chars();
80    let valid = chars.next()?.is_ascii_alphabetic()
81        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
82    valid.then_some(end + 1)
83}
84
85/// pi's `redactUrl`: `value` without the user info and the values of
86/// secret-looking query parameters of the URL it is, or unchanged when it
87/// is no URL or has neither.
88fn redact_url(value: &str) -> String {
89    // A package source such as `git:https://...` wraps a URL in a scheme.
90    if let Some(prefix) = scheme_len(value) {
91        let rest = &value[prefix..];
92        if scheme_len(rest).is_some_and(|end| rest[end..].starts_with("//")) {
93            return format!("{}{}", &value[..prefix], redact_url(rest));
94        }
95    }
96    let Ok(mut url) = url::Url::parse(value) else {
97        return value.to_owned();
98    };
99    let mut changed = false;
100    if !url.username().is_empty() || url.password().is_some() {
101        let _ = url.set_username("");
102        let _ = url.set_password(None);
103        changed = true;
104    }
105    if url.query_pairs().any(|(key, _)| is_sensitive_key(&key)) {
106        let pairs: Vec<(String, String)> = url
107            .query_pairs()
108            .map(|(key, value)| {
109                let value = if is_sensitive_key(&key) {
110                    REDACTED.to_owned()
111                } else {
112                    value.into_owned()
113                };
114                (key.into_owned(), value)
115            })
116            .collect();
117        url.query_pairs_mut().clear().extend_pairs(pairs);
118        changed = true;
119    }
120    if changed {
121        url.into()
122    } else {
123        value.to_owned()
124    }
125}
126
127/// pi's `redactJsonValue`, in place: the values of credential keys become
128/// `<redacted>`, and every string goes through [`redact_url`].
129fn redact(value: &mut Value) {
130    match value {
131        Value::Object(map) => {
132            for (key, child) in map.iter_mut() {
133                if !child.is_null() && is_sensitive_key(key) {
134                    *child = Value::String(REDACTED.to_owned());
135                } else {
136                    redact(child);
137                }
138            }
139        }
140        Value::Array(items) => items.iter_mut().for_each(redact),
141        Value::String(text) => *text = redact_url(text),
142        _ => {}
143    }
144}
145
146/// The names pi's `process.platform` and `process.arch` give the build's
147/// operating system and processor.
148fn node_platform() -> (&'static str, &'static str) {
149    let platform = match std::env::consts::OS {
150        "macos" => "darwin",
151        "windows" => "win32",
152        os => os,
153    };
154    let arch = match std::env::consts::ARCH {
155        "x86_64" => "x64",
156        "aarch64" => "arm64",
157        "x86" => "ia32",
158        arch => arch,
159    };
160    (platform, arch)
161}
162
163/// Node's `os.release()` and `os.version()`.
164#[cfg(unix)]
165fn os_release() -> (Option<String>, Option<String>) {
166    let name = rustix::system::uname();
167    let text = |value: &std::ffi::CStr| Some(value.to_string_lossy().into_owned());
168    (text(name.release()), text(name.version()))
169}
170
171#[cfg(not(unix))]
172fn os_release() -> (Option<String>, Option<String>) {
173    (None, None)
174}
175
176/// pi's `collectEnvironment` for yapi.
177fn environment() -> Value {
178    let env = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty());
179    let mut names: Vec<String> = std::env::vars_os()
180        .filter_map(|(name, _)| name.into_string().ok())
181        .filter(|name| name.starts_with("PI_") || name.starts_with("YAPI_"))
182        .collect();
183    names.sort();
184    let version = env!("CARGO_PKG_VERSION");
185    let build = if cfg!(debug_assertions) {
186        "debug"
187    } else {
188        "release"
189    };
190    let (platform, arch) = node_platform();
191    let (os_release, os_version) = os_release();
192    let ssh = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
193        .into_iter()
194        .any(|name| env(name).is_some());
195    json!({
196        "version": version,
197        "userAgent": yapi_ai::http::user_agent(),
198        "runtime": format!("yapi/{version} ({build})"),
199        "platform": platform,
200        "arch": arch,
201        "osRelease": os_release,
202        "osVersion": os_version,
203        "shell": env("SHELL").and_then(|shell| {
204            shell.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()).map(str::to_owned)
205        }),
206        "terminal": {
207            "term": env("TERM"),
208            "program": env("TERM_PROGRAM"),
209            "programVersion": env("TERM_PROGRAM_VERSION"),
210            "colorterm": env("COLORTERM"),
211            "tmux": env("TMUX").is_some(),
212            "ssh": ssh,
213            "ci": env("CI").is_some(),
214        },
215        // Names help diagnose configuration, and values never leave the machine.
216        "piEnvironmentVariables": names,
217    })
218}
219
220fn sorted_names<'a>(headers: impl IntoIterator<Item = &'a String>) -> Vec<&'a String> {
221    let mut names: Vec<&String> = headers.into_iter().collect();
222    names.sort();
223    names
224}
225
226fn describe_model(model: &Model) -> Value {
227    json!({
228        "provider": model.provider,
229        "id": model.id,
230        "name": model.name,
231        "api": model.api,
232        "baseUrl": model.base_url,
233        "reasoning": model.reasoning,
234        "input": model.input,
235        "contextWindow": model.context_window,
236        "maxTokens": model.max_tokens,
237        "samplingParams": model.sampling_params,
238        "compat": model.compat,
239        "thinkingLevelMap": model.thinking_level_map,
240        "headerNames": sorted_names(model.headers.iter().flat_map(|headers| headers.keys())),
241    })
242}
243
244fn describe_provider(registry: &ModelRegistry, id: &str) -> Value {
245    let config = registry.provider_config(id);
246    let mut auth_types = Vec::new();
247    if yapi_ai::providers::info(id).is_none_or(|info| info.api_key.is_some()) {
248        auth_types.push("api_key");
249    }
250    if registry.oauth_flow(id).is_some() {
251        auth_types.push("oauth");
252    }
253    let auth_status = match registry.login_status(id) {
254        Some(source) => json!({"configured": true, "source": source}),
255        None => json!({"configured": false}),
256    };
257    json!({
258        "id": id,
259        "name": registry.provider_name(id),
260        "baseUrl": config.and_then(|config| config.base_url.clone()),
261        "headerNames": sorted_names(config.and_then(|config| config.headers.as_ref()).into_iter().flat_map(|headers| headers.keys())),
262        "authTypes": auth_types,
263        "authStatus": auth_status,
264        "usingOAuth": registry.is_using_oauth(id),
265        "registeredByExtension": registry.registered_by_extension(id),
266    })
267}
268
269/// A settings document without pi's tracking ids.
270fn settings(session: &AgentSession, scope: Scope) -> Value {
271    let mut document = session.settings_manager().document(scope).clone();
272    document.retain(|key, _| key != "trackingId" && key != "deviceId");
273    Value::Object(document)
274}
275
276/// pi's `collectBugReportDiagnostics` over the current branch: the failed,
277/// aborted and diagnostic assistant turns, without conversation text.
278fn diagnostics(session: &SessionManager) -> Value {
279    let branch = session.branch_path(None);
280    let mut assistant = Vec::new();
281    let mut count = 0;
282    for entry in &branch {
283        let FileEntry::Message(entry) = entry else {
284            continue;
285        };
286        let Message::Assistant(message) = &entry.message else {
287            continue;
288        };
289        count += 1;
290        let diagnostics = message.diagnostics.clone().unwrap_or_default();
291        let failed = matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
292            || message
293                .error_message
294                .as_deref()
295                .is_some_and(|m| !m.is_empty());
296        if diagnostics.is_empty() && !failed {
297            continue;
298        }
299        let mut turn = json!({
300            "entryId": entry.meta.id,
301            "timestamp": entry.meta.timestamp,
302            "provider": message.provider,
303            "model": message.model,
304            "api": message.api,
305            "stopReason": message.stop_reason,
306        });
307        if let Some(reason) = &message.raw_stop_reason {
308            turn["rawStopReason"] = json!(reason);
309        }
310        if let Some(error) = &message.error_message {
311            turn["errorMessage"] = json!(error);
312        }
313        turn["diagnostics"] = json!(diagnostics);
314        assistant.push(turn);
315    }
316    json!({
317        "schemaVersion": SCHEMA_VERSION,
318        "sessionId": session.id(),
319        "entryCount": branch.len(),
320        "assistantMessageCount": count,
321        "assistant": assistant,
322    })
323}
324
325/// A report's files, before they are written.
326pub struct Report {
327    /// The report id, a UUID v7.
328    pub id: String,
329    /// `report.json`: the environment, model, provider, extensions and
330    /// settings, redacted.
331    metadata: Value,
332    /// `diagnostics.json`: the failed turns of the branch, redacted.
333    diagnostics: Value,
334    /// `session.jsonl`: the branch, when the user includes the transcript.
335    session: Option<String>,
336    /// `summary.md`: the model's summary, when the user asked for one.
337    summary: Option<String>,
338}
339
340impl Report {
341    /// pi's `collectBugReportMetadata` and `collectBugReportDiagnostics`
342    /// for `session`, with description `hint`, the transcript when
343    /// `include_session`, and `summary`.
344    pub fn collect(
345        session: &AgentSession,
346        hint: &str,
347        include_session: bool,
348        summary: Option<String>,
349    ) -> Report {
350        let id = uuid_v7();
351        let (session_id, cwd, jsonl, mut diagnostics) = session.with_session(|file| {
352            (
353                file.id().to_owned(),
354                file.cwd().display().to_string(),
355                include_session.then(|| file.serialize_branch()),
356                diagnostics(file),
357            )
358        });
359        let mut session_info = json!({
360            "id": session_id,
361            "included": include_session,
362            "summaryIncluded": summary.is_some(),
363            "messageCount": session.messages().len(),
364        });
365        if include_session {
366            session_info["cwd"] = json!(cwd);
367        }
368        let model = session.model();
369        let registry = session.registry();
370        let extensions: Vec<Value> = session
371            .extensions()
372            .iter()
373            .map(|extension| {
374                let source = extension.source();
375                // pi hides its built-in extensions.
376                let hidden = source.source == "builtin";
377                json!({
378                    "path": source.path,
379                    "source": source.source,
380                    "scope": source.scope,
381                    "origin": source.origin,
382                    "hidden": hidden,
383                })
384            })
385            .collect();
386        let errors: Vec<Value> = session
387            .extension_load_errors()
388            .into_iter()
389            .map(|(path, error)| json!({"path": path, "error": error}))
390            .collect();
391        let hint = hint.trim();
392        let mut metadata = json!({
393            "schemaVersion": SCHEMA_VERSION,
394            "id": id,
395            "createdAt": now_iso(),
396            "hint": (!hint.is_empty()).then_some(hint),
397            "environment": environment(),
398            "session": session_info,
399            "model": model.as_ref().map(describe_model),
400            "provider": model.as_ref().map(|model| describe_provider(&registry, &model.provider)),
401            "thinkingLevel": session.thinking_level().as_str(),
402            "extensions": extensions,
403            "extensionErrors": errors,
404            "settings": {
405                "global": settings(session, Scope::Global),
406                "project": settings(session, Scope::Project),
407            },
408        });
409        redact(&mut metadata);
410        redact(&mut diagnostics);
411        Report {
412            id,
413            metadata,
414            diagnostics,
415            session: jsonl,
416            summary,
417        }
418    }
419
420    /// The zip's file name.
421    pub fn file_name(&self) -> String {
422        format!("yapi-bug-report-{}.zip", self.id)
423    }
424
425    /// The description the user gave, after redaction.
426    fn hint(&self) -> Option<&str> {
427        self.metadata["hint"].as_str()
428    }
429
430    /// The zip's files, by name, in pi's order.
431    fn files(&self) -> Vec<(&'static str, String)> {
432        let mut files = vec![
433            ("report.json", pretty(&self.metadata)),
434            ("diagnostics.json", pretty(&self.diagnostics)),
435        ];
436        if let Some(session) = &self.session {
437            files.push(("session.jsonl", session.clone()));
438        }
439        if let Some(summary) = &self.summary {
440            let newline = if summary.ends_with('\n') { "" } else { "\n" };
441            files.push(("summary.md", format!("{summary}{newline}")));
442        }
443        files
444    }
445
446    /// Writes the zip to `path`.
447    pub fn write_zip(&self, path: &Path) -> io::Result<()> {
448        std::fs::write(path, zip(&self.files(), dos_time(now_ms()))?)
449    }
450
451    /// The issue title: the description's first line, or the report id.
452    pub fn title(&self) -> String {
453        match self.hint().and_then(|hint| hint.lines().next()) {
454            Some(line) => line.chars().take(TITLE_LIMIT).collect(),
455            None => format!("yapi bug report {}", self.id),
456        }
457    }
458
459    /// The issue body: the description, the summary and `report.json` with
460    /// only the names of the settings, within GitHub's limit, with `home`
461    /// written as `~`. The transcript and the diagnostics, which can quote
462    /// tool output, stay out.
463    pub fn issue_body(&self, home: &Path) -> String {
464        let mut head = self.hint().unwrap_or("No description given.").to_owned();
465        if let Some(summary) = &self.summary {
466            head.push_str(&format!("\n\n## Summary\n\n{summary}"));
467        }
468        let mut metadata = self.metadata.clone();
469        // Redaction goes by key name, and a value such as `shellCommandPrefix`
470        // can hold a token under any name.
471        if let Some(scopes) = metadata["settings"].as_object_mut() {
472            for scope in scopes.values_mut() {
473                if let Some(settings) = scope.as_object() {
474                    *scope = settings.keys().cloned().collect();
475                }
476            }
477        }
478        let report = pretty(&metadata);
479        let longest = report.split(|c| c != '`').map(str::len).max().unwrap_or(0);
480        let fence = "`".repeat(longest.max(2) + 1);
481        let open = format!("\n\n## Report\n\n{fence}json\n");
482        let close = format!("{fence}\n");
483        let head = truncate(&head, BODY_LIMIT / 2);
484        let room = BODY_LIMIT - head.len() - open.len() - close.len();
485        // Only shortens the body, so it stays within the limit.
486        tilde(
487            &format!("{head}{open}{}{close}", truncate(&report, room)),
488            home,
489        )
490    }
491
492    /// The `yapi.bug-report` session entry's data for the zip at `path`
493    /// and the issue at `issue_url`.
494    pub fn entry(&self, path: &Path, issue_url: Option<&str>) -> Value {
495        let mut entry = json!({
496            "id": self.id,
497            "createdAt": self.metadata["createdAt"],
498            "hint": self.metadata["hint"],
499            "sessionIncluded": self.session.is_some(),
500            "summaryIncluded": self.summary.is_some(),
501            "delivery": "zip",
502            "path": path.display().to_string(),
503        });
504        if let Some(url) = issue_url {
505            entry["issueUrl"] = json!(url);
506        }
507        entry
508    }
509}
510
511/// `text` with each whole `home` path, such as one the summary quotes from an
512/// error, written as `~`, so a public issue does not name the user.
513fn tilde(text: &str, home: &Path) -> String {
514    let home = home.to_string_lossy();
515    let home = home.trim_end_matches('/');
516    if home.is_empty() {
517        return text.to_owned();
518    }
519    let mut out = String::with_capacity(text.len());
520    let mut rest = text;
521    while let Some(at) = rest.find(home) {
522        let after = &rest[at + home.len()..];
523        let whole = !after.starts_with(|c: char| c.is_alphanumeric() || "-_.".contains(c));
524        out.push_str(&rest[..at]);
525        out.push_str(if whole { "~" } else { home });
526        rest = after;
527    }
528    out.push_str(rest);
529    out
530}
531
532/// `value` as `JSON.stringify(value, null, 2)` and a newline.
533fn pretty(value: &Value) -> String {
534    yapi_types::json::to_string_pretty(value, "  ").unwrap_or_default() + "\n"
535}
536
537/// `text` cut to at most `max` bytes, marked when cut.
538fn truncate(text: &str, max: usize) -> String {
539    const MARK: &str = "\n[truncated]\n";
540    if text.len() <= max {
541        return text.to_owned();
542    }
543    let end = text.floor_char_boundary(max.saturating_sub(MARK.len()));
544    format!("{}{MARK}", &text[..end])
545}
546
547/// The local time at `ms` as MS-DOS time and date, as zip headers keep it.
548fn dos_time(ms: u64) -> (u16, u16) {
549    let seconds = (ms / 1000) as i64;
550    let local = seconds + i64::from(crate::zone::local().offset(seconds));
551    let iso = crate::time::iso(local.max(0) as u64 * 1000);
552    let field = |at: usize, len: usize| {
553        iso.get(at..at + len)
554            .and_then(|text| text.parse::<u16>().ok())
555            .unwrap_or(0)
556    };
557    let (year, month, day) = (field(0, 4), field(5, 2), field(8, 2));
558    let (hour, minute, second) = (field(11, 2), field(14, 2), field(17, 2));
559    (
560        hour << 11 | minute << 5 | second >> 1,
561        (year.max(1980) - 1980) << 9 | month << 5 | day,
562    )
563}
564
565/// pi's `createZipArchive`: a classic zip of deflated `files` with UTF-8
566/// names, stamped `(time, date)`.
567// shortcut: no ZIP64, so sizes over 4 GiB wrap, add ZIP64 if a report ever gets that large.
568fn zip(files: &[(&str, String)], (time, date): (u16, u16)) -> io::Result<Vec<u8>> {
569    let mut out = Vec::new();
570    let mut directory = Vec::new();
571    for (name, data) in files {
572        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
573        encoder.write_all(data.as_bytes())?;
574        let compressed = encoder.finish()?;
575        let mut crc = flate2::Crc::new();
576        crc.update(data.as_bytes());
577        let offset = out.len() as u32;
578        // From "version needed" to the extra field's length, which both headers share.
579        let fields = |header: &mut Vec<u8>| {
580            for value in [20, 0x0800, 8, time, date] {
581                header.extend(value.to_le_bytes());
582            }
583            for value in [crc.sum(), compressed.len() as u32, data.len() as u32] {
584                header.extend(value.to_le_bytes());
585            }
586            header.extend((name.len() as u16).to_le_bytes());
587            header.extend(0u16.to_le_bytes());
588        };
589        out.extend(0x0403_4b50u32.to_le_bytes());
590        fields(&mut out);
591        out.extend(name.as_bytes());
592        out.extend(&compressed);
593        directory.extend(0x0201_4b50u32.to_le_bytes());
594        directory.extend(20u16.to_le_bytes());
595        fields(&mut directory);
596        // Comment length, disk, internal and external attributes.
597        directory.extend([0; 10]);
598        directory.extend(offset.to_le_bytes());
599        directory.extend(name.as_bytes());
600    }
601    let count = (files.len() as u16).to_le_bytes();
602    let (directory_len, directory_at) = (directory.len() as u32, out.len() as u32);
603    out.extend(directory);
604    out.extend(0x0605_4b50u32.to_le_bytes());
605    out.extend([0; 4]);
606    out.extend(count);
607    out.extend(count);
608    out.extend(directory_len.to_le_bytes());
609    out.extend(directory_at.to_le_bytes());
610    out.extend([0; 2]);
611    Ok(out)
612}
613
614const SUMMARY_SYSTEM_PROMPT: &str = "You are helping a user file a bug report about yapi, the coding agent they are talking to. You will be shown the conversation transcript. Write a report for the yapi developers describing what the user was doing and what went wrong.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the report.";
615
616const SUMMARY_INSTRUCTIONS: &str = "Write the bug report in Markdown with these sections:\n\n## What the user was doing\nOne short paragraph.\n\n## What went wrong\nConcrete description of the failure: wrong output, errors, hangs, tool failures, unexpected behavior. Quote error messages and tool output verbatim where they exist.\n\n## Steps to reproduce\nNumbered list, as specific as the transcript allows.\n\n## Relevant details\nTool calls involved, files touched, model behavior, anything else that helps a developer reproduce or locate the problem.\n\nDo not include file contents, secrets, or credentials from the transcript; refer to files by path only. The report is public: write paths inside the user's home folder as ~/..., even in quoted errors, and never state the user's name or where their home folder is. Keep the report factual and concise.";
617
618/// pi's `generateBugReportSummary`: a report `summarizer`'s model writes
619/// without tools from the last `messages` that fit in 60% of its context
620/// window, with the user's `hint`.
621pub(crate) async fn summary(
622    summarizer: &Summarizer<'_>,
623    messages: &[Message],
624    hint: Option<&str>,
625) -> Result<String, String> {
626    let model = summarizer.model;
627    let window = if model.context_window > 0 {
628        model.context_window
629    } else {
630        128_000
631    };
632    let budget = window * 6 / 10;
633    let (mut start, mut tokens) = (messages.len(), 0);
634    while start > 0 {
635        let next = estimate_tokens(&messages[start - 1]);
636        if start < messages.len() && tokens + next > budget {
637            break;
638        }
639        tokens += next;
640        start -= 1;
641    }
642    let shown = &messages[start..];
643    let mut parts = Vec::new();
644    if start > 0 {
645        parts.push(format!(
646            "Note: only the last {} of {} messages are shown.",
647            shown.len(),
648            messages.len()
649        ));
650    }
651    let conversation = serialize_conversation(&convert_to_llm(shown.to_vec()));
652    parts.push(format!("<conversation>\n{conversation}\n</conversation>"));
653    if let Some(hint) = hint.map(str::trim).filter(|hint| !hint.is_empty()) {
654        parts.push(format!("<user-report>\n{hint}\n</user-report>"));
655    }
656    parts.push(SUMMARY_INSTRUCTIONS.to_owned());
657    let max_tokens = if model.max_tokens > 0 {
658        model.max_tokens.min(4096)
659    } else {
660        4096
661    };
662    let response = summarizer
663        .complete(SUMMARY_SYSTEM_PROMPT, &parts.join("\n\n"), max_tokens)
664        .await;
665    if response.stop_reason == StopReason::Aborted {
666        return Err("Bug report summary was cancelled".to_owned());
667    }
668    check_summary(&response, "Bug report summary")?;
669    let text = blocks_text(&response.content, "\n").trim().to_owned();
670    if text.is_empty() {
671        return Err("Bug report summary was empty".to_owned());
672    }
673    Ok(text)
674}
675
676/// Whether the GitHub CLI at `program` can file an issue: not `offline`,
677/// installed and signed in. Blocks while `gh auth status` runs.
678pub fn github_ready(program: &OsStr, offline: bool) -> bool {
679    !offline
680        && Command::new(program)
681            .args(["auth", "status"])
682            .stdin(Stdio::null())
683            .stdout(Stdio::null())
684            .stderr(Stdio::null())
685            .status()
686            .is_ok_and(|status| status.success())
687}
688
689/// Files a public issue on [`REPO`] with the GitHub CLI at `program`,
690/// passing `body` through `body_file`, and returns the issue's URL. Blocks
691/// while `gh` runs.
692pub fn create_issue(
693    program: &OsStr,
694    title: &str,
695    body: &str,
696    body_file: &Path,
697) -> Result<String, String> {
698    std::fs::write(body_file, body).map_err(|error| error.to_string())?;
699    let output = Command::new(program)
700        .args([
701            "issue",
702            "create",
703            "--repo",
704            REPO,
705            "--title",
706            title,
707            "--body-file",
708        ])
709        .arg(body_file)
710        .stdin(Stdio::null())
711        .output();
712    let _ = std::fs::remove_file(body_file);
713    let output = output.map_err(|error| error.to_string())?;
714    if !output.status.success() {
715        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
716        return Err(if stderr.is_empty() {
717            format!("gh failed with {}", output.status)
718        } else {
719            stderr
720        });
721    }
722    let stdout = String::from_utf8_lossy(&output.stdout);
723    Ok(stdout.lines().last().unwrap_or_default().trim().to_owned())
724}
725
726#[cfg(test)]
727mod tests {
728    use std::io::Read;
729
730    use super::*;
731
732    #[test]
733    fn redacts_credentials() {
734        let mut value = json!({
735            "apiKey": "sk-1",
736            "nested": {"x-api-key": "sk-2", "Authorization": "Bearer t", "maxTokens": 5},
737            "cookies": "kept",
738            "proxy": "https://user:pass@proxy.example/path?access_token=abc&q=1",
739            "source": "git:https://me:tok@github.com/o/r",
740            "plain": "not a url",
741            "empty": null,
742            "list": [{"refreshToken": "r"}],
743        });
744        redact(&mut value);
745        assert_eq!(
746            value,
747            json!({
748                "apiKey": "<redacted>",
749                "nested": {"x-api-key": "<redacted>", "Authorization": "<redacted>", "maxTokens": 5},
750                "cookies": "kept",
751                "proxy": "https://proxy.example/path?access_token=%3Credacted%3E&q=1",
752                "source": "git:https://github.com/o/r",
753                "plain": "not a url",
754                "empty": null,
755                "list": [{"refreshToken": "<redacted>"}],
756            })
757        );
758        assert_eq!(
759            redact_url("https://example.com/a?b=c"),
760            "https://example.com/a?b=c"
761        );
762    }
763
764    fn report(session: Option<&str>, summary: Option<&str>) -> Report {
765        Report {
766            id: "0190-id".into(),
767            metadata: json!({"id": "0190-id", "hint": "It broke\nafter /reload", "createdAt": "t"}),
768            diagnostics: json!({"assistant": [{"errorMessage": "DIAGNOSTIC-MARKER"}]}),
769            session: session.map(str::to_owned),
770            summary: summary.map(str::to_owned),
771        }
772    }
773
774    /// The names and texts of a zip's entries, checked against their CRCs.
775    fn unzip(bytes: &[u8]) -> Vec<(String, String)> {
776        let u16_at = |at: usize| usize::from(u16::from_le_bytes([bytes[at], bytes[at + 1]]));
777        let u32_at = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
778        let (mut entries, mut at) = (Vec::new(), 0);
779        while u32_at(at) == 0x0403_4b50 {
780            let (size, name_len) = (u32_at(at + 18) as usize, u16_at(at + 26));
781            let name = String::from_utf8(bytes[at + 30..at + 30 + name_len].to_vec()).unwrap();
782            let start = at + 30 + name_len;
783            let mut text = String::new();
784            flate2::read::DeflateDecoder::new(&bytes[start..start + size])
785                .read_to_string(&mut text)
786                .unwrap();
787            let mut crc = flate2::Crc::new();
788            crc.update(text.as_bytes());
789            assert_eq!(crc.sum(), u32_at(at + 14));
790            entries.push((name, text));
791            at = start + size;
792        }
793        assert_eq!(u32_at(at), 0x0201_4b50);
794        entries
795    }
796
797    #[test]
798    fn zip_contents() {
799        let dir = std::env::temp_dir().join(format!("yapi-bug-zip-{}", crate::time::uuid_v4()));
800        std::fs::create_dir_all(&dir).unwrap();
801        let declined = report(None, None);
802        assert_eq!(declined.file_name(), "yapi-bug-report-0190-id.zip");
803        let path = dir.join(declined.file_name());
804        declined.write_zip(&path).unwrap();
805        let entries = unzip(&std::fs::read(&path).unwrap());
806        let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect();
807        assert_eq!(names, ["report.json", "diagnostics.json"]);
808        assert!(entries[0].1.starts_with("{\n  \"id\": \"0190-id\","));
809        let full = report(Some("{\"type\":\"session\"}\n"), Some("## What went wrong"));
810        full.write_zip(&path).unwrap();
811        let entries = unzip(&std::fs::read(&path).unwrap());
812        let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect();
813        assert_eq!(
814            names,
815            [
816                "report.json",
817                "diagnostics.json",
818                "session.jsonl",
819                "summary.md"
820            ]
821        );
822        assert_eq!(entries[3].1, "## What went wrong\n");
823        let _ = std::fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn issue_body_tilde_home() {
828        let report = report(
829            None,
830            Some("ENOENT: '/home/me/work/a.txt' in /home/me, not /home/meg or /home/me.d"),
831        );
832        let body = report.issue_body(Path::new("/home/me/"));
833        assert!(
834            body.contains("ENOENT: '~/work/a.txt' in ~, not /home/meg or /home/me.d"),
835            "{body}"
836        );
837        assert_eq!(tilde("/a/b", Path::new("/")), "/a/b");
838    }
839
840    #[test]
841    fn issue_body_omits_settings() {
842        let mut report = report(None, None);
843        report.metadata["settings"] = json!({
844            "global": {"shellCommandPrefix": "export GITHUB_TOKEN=ghp_secret", "apiKey": "sk-1"},
845            "project": {},
846        });
847        redact(&mut report.metadata);
848        let body = report.issue_body(Path::new("/home/me"));
849        assert!(body.contains("\"shellCommandPrefix\""), "{body}");
850        assert!(
851            !body.contains("ghp_secret") && !body.contains("<redacted>"),
852            "{body}"
853        );
854        let zipped = &report.files()[0].1;
855        assert!(
856            zipped.contains("export GITHUB_TOKEN=ghp_secret"),
857            "{zipped}"
858        );
859        assert!(zipped.contains("\"apiKey\": \"<redacted>\""), "{zipped}");
860    }
861
862    #[test]
863    fn issue_body_size_limit() {
864        let mut long = report(None, Some(&"s".repeat(BODY_LIMIT)));
865        long.metadata["settings"] = json!("r".repeat(BODY_LIMIT));
866        let body = long.issue_body(Path::new("/home/me"));
867        assert!(body.len() <= BODY_LIMIT, "{}", body.len());
868        assert!(body.ends_with("[truncated]\n```\n"));
869        assert_eq!(long.title(), "It broke");
870        let mut untitled = report(None, None);
871        untitled.metadata["hint"] = Value::Null;
872        assert_eq!(untitled.title(), "yapi bug report 0190-id");
873    }
874
875    /// A session on model `m` of provider `p`, which `faux` answers.
876    fn faux_session(faux: &yapi_ai::faux::Faux) -> AgentSession {
877        let model: Model = serde_json::from_value(json!({
878            "id": "m", "name": "M", "api": "faux", "provider": "p", "baseUrl": "",
879            "reasoning": false, "input": ["text"],
880            "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
881            "contextWindow": 1000, "maxTokens": 100,
882        }))
883        .unwrap();
884        let mut registry = ModelRegistry::builtin();
885        registry.register_provider("p", vec![model.clone()]);
886        registry.set_runtime_key("p", "sk-SECRET".into());
887        let mut apis = yapi_ai::api::Apis::default();
888        apis.register(std::sync::Arc::new(faux.clone()));
889        let session = AgentSession::new(crate::agent_session::SessionConfig {
890            cwd: "/work".into(),
891            agent_dir: "/agent".into(),
892            settings: crate::settings::SettingsManager::in_memory(),
893            registry,
894            apis,
895            session: SessionManager::in_memory(Path::new("/work")),
896            model: Some(model),
897            thinking_level: yapi_types::message::ThinkingLevel::Off,
898            tools: Vec::new(),
899            extensions: Vec::new(),
900            include_extension_tools: false,
901            allowed_tools: None,
902            excluded_tools: Vec::new(),
903            resources: crate::agent_session::Resources::default(),
904            docs: crate::docs::Locations::default(),
905        });
906        let message = Message::User(yapi_types::message::UserMessage {
907            content: yapi_types::message::Content::Text("the agent hangs".into()),
908            timestamp: 0,
909        });
910        session
911            .with_session(|file| file.append_message(message))
912            .unwrap();
913        session
914    }
915
916    #[test]
917    fn cwd_and_transcript_optional() {
918        let session = faux_session(&yapi_ai::faux::Faux::default());
919        let declined = Report::collect(&session, "  it hangs ", false, None);
920        assert_eq!(declined.metadata["hint"], "it hangs");
921        assert_eq!(declined.metadata["session"].get("cwd"), None);
922        assert_eq!(declined.metadata["model"]["id"], "m");
923        assert_eq!(
924            declined.metadata["provider"]["authStatus"]["source"],
925            "runtime"
926        );
927        assert!(declined.session.is_none());
928        assert!(!pretty(&declined.metadata).contains("sk-SECRET"));
929        let included = Report::collect(&session, "", true, None);
930        assert_eq!(included.metadata["hint"], Value::Null);
931        assert_eq!(included.metadata["session"]["cwd"], "/work");
932        assert!(included.session.unwrap().contains("the agent hangs"));
933        assert_eq!(included.diagnostics["assistantMessageCount"], 0);
934    }
935
936    #[tokio::test]
937    async fn summary_rejects_bad_output() {
938        use tokio_util::sync::CancellationToken;
939        use yapi_ai::faux::{Faux, Response};
940
941        let faux = Faux::new([
942            Response::text(" ## What went wrong\nIt hung. "),
943            Response::tool_call("t", "read", json!({})),
944            Response::text(" "),
945        ]);
946        let session = faux_session(&faux);
947        let summary = session.bug_report_summary(Some(" after /reload "), CancellationToken::new());
948        assert_eq!(summary.await.as_deref(), Ok("## What went wrong\nIt hung."));
949        let request = format!("{:?}", faux.requests()[0]);
950        assert!(request.contains("a bug report about yapi"), "{request}");
951        assert!(request.contains("home folder as ~/..."), "{request}");
952        assert!(request.contains("the agent hangs"));
953        assert!(request.contains("<user-report>\\nafter /reload\\n</user-report>"));
954        let tool = session
955            .bug_report_summary(None, CancellationToken::new())
956            .await;
957        assert_eq!(
958            tool,
959            Err("Bug report summary attempted to call a tool".to_owned())
960        );
961        let empty = session
962            .bug_report_summary(None, CancellationToken::new())
963            .await;
964        assert_eq!(empty, Err("Bug report summary was empty".to_owned()));
965    }
966
967    /// `gh issue create` gets its arguments as given, never through a
968    /// shell, and a body file without the transcript or the diagnostics.
969    #[cfg(unix)]
970    #[test]
971    fn issue_create_avoids_shell() {
972        use std::os::unix::fs::PermissionsExt;
973        let dir = std::env::temp_dir().join(format!("yapi-bug-gh-{}", crate::time::uuid_v4()));
974        std::fs::create_dir_all(&dir).unwrap();
975        let gh = dir.join("gh");
976        let script = format!(
977            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{dir}/args'\nwhile [ $# -gt 0 ]; do [ \"$1\" = --body-file ] && cp \"$2\" '{dir}/body'; shift; done\necho https://github.com/SkymanOne/yapi/issues/7\n",
978            dir = dir.display()
979        );
980        std::fs::write(&gh, script).unwrap();
981        std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap();
982        let report = report(Some("TRANSCRIPT-MARKER"), Some("SUMMARY-MARKER"));
983        let title = format!("$(touch {0}/pwned); `touch {0}/pwned` \"'", dir.display());
984        let body_file = dir.join("issue.md");
985        let body = report.issue_body(Path::new("/home/me"));
986        let url = create_issue(gh.as_os_str(), &title, &body, &body_file);
987        assert_eq!(
988            url.as_deref(),
989            Ok("https://github.com/SkymanOne/yapi/issues/7")
990        );
991        let args = std::fs::read_to_string(dir.join("args")).unwrap();
992        let expected = [
993            "issue",
994            "create",
995            "--repo",
996            "SkymanOne/yapi",
997            "--title",
998            title.as_str(),
999            "--body-file",
1000        ];
1001        assert_eq!(args.lines().take(7).collect::<Vec<_>>(), expected);
1002        assert_eq!(args.lines().nth(7), Some(body_file.to_str().unwrap()));
1003        assert!(!dir.join("pwned").exists());
1004        assert!(!body_file.exists());
1005        let body = std::fs::read_to_string(dir.join("body")).unwrap();
1006        assert!(body.starts_with("It broke\nafter /reload\n\n## Summary\n\nSUMMARY-MARKER"));
1007        assert!(body.contains("\"createdAt\": \"t\""));
1008        assert!(!body.contains("TRANSCRIPT-MARKER") && !body.contains("DIAGNOSTIC-MARKER"));
1009        let _ = std::fs::remove_dir_all(&dir);
1010    }
1011}