Skip to main content

git_slop/report_ops/
github.rs

1use super::*;
2
3pub fn health_json_payload(report: &Value) -> Value {
4    report
5        .get("health")
6        .cloned()
7        .unwrap_or_else(|| json!({"findings": []}))
8}
9
10fn github_property_escape(value: &str) -> String {
11    value
12        .replace('%', "%25")
13        .replace('\r', "%0D")
14        .replace('\n', "%0A")
15        .replace(':', "%3A")
16        .replace(',', "%2C")
17}
18
19fn github_message_escape(value: &str) -> String {
20    value
21        .replace('%', "%25")
22        .replace('\r', "%0D")
23        .replace('\n', "%0A")
24}
25
26fn github_annotation_level(severity: &str) -> &'static str {
27    if severity.eq_ignore_ascii_case("notice") {
28        "notice"
29    } else if severity.eq_ignore_ascii_case("error") {
30        "error"
31    } else {
32        "warning"
33    }
34}
35
36pub fn render_github_annotations(report: &Value, max_annotations: usize) -> String {
37    let lines: Vec<String> = array_at(report, &["health", "findings"])
38        .iter()
39        .take(max_annotations)
40        .map(|finding| {
41            let severity = string(finding.get("severity"));
42            let command = github_annotation_level(&severity);
43            let path = github_property_escape(&string(finding.get("path")));
44            let title = github_property_escape(&string(finding.get("title")));
45            let mut message = string(finding.get("message"));
46            let next_command = string(finding.get("next_command"));
47            if !next_command.is_empty() {
48                if !message.is_empty() && !message.chars().last().is_some_and(char::is_whitespace) {
49                    message.push(' ');
50                }
51                message.push_str("Next: ");
52                message.push_str(&next_command);
53            }
54            format!(
55                "::{command} file={path},title={title}::{}",
56                github_message_escape(&message)
57            )
58        })
59        .collect();
60    if lines.is_empty() {
61        String::new()
62    } else {
63        format!("{}\n", lines.join("\n"))
64    }
65}
66
67pub fn write_prompt_pack(
68    command: &str,
69    payload: &Value,
70    report: &Value,
71    output_dir: &Path,
72) -> Result<()> {
73    if output_dir.exists() && !output_dir.is_dir() {
74        bail!(
75            "Prompt pack path is not a directory: {}",
76            output_dir.display()
77        );
78    }
79    fs::create_dir_all(output_dir)?;
80    let context = json!({
81        "prompt_pack_version": 1,
82        "command": command,
83        "payload": payload,
84        "report_excerpt": {
85            "schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
86            "repo": report.get("repo").cloned().unwrap_or_else(|| json!({})),
87            "summary": report.get("summary").cloned().unwrap_or_else(|| json!({})),
88            "stats": report.get("stats").cloned().unwrap_or_else(|| json!({})),
89        },
90        "boundary": "Local model output is advisory only and must not rescore detector truth or mutate code, GitHub, or report data.",
91    });
92    fs::write(output_dir.join("context.json"), render_json(&context)?)?;
93    fs::write(
94        output_dir.join("prompt.md"),
95        format!(
96            "# git-slop {command} prompt pack\n\n\
97             Use only the facts in context.json.\n\n\
98             Summarize the selected detector evidence for a maintainer. Keep hotspot\n\
99             costs separate from overlay evidence. Treat model output as advisory:\n\
100             do not rescore detector truth, claim correctness, or imply a refactor is\n\
101             mandatory. If the supplied facts are insufficient, say what is missing\n\
102             instead of inventing context.\n\n\
103             Preferred output:\n\n\
104             1. Brief maintainer summary\n\
105             2. Strongest evidence\n\
106             3. Suggested next review step\n\
107             4. Boundaries and non-claims\n"
108        ),
109    )?;
110    fs::write(
111        output_dir.join("README.md"),
112        format!(
113            "# git-slop {command} Prompt Pack\n\n\
114             This directory was generated by git-slop for local model use.\n\n\
115             Files:\n\n\
116             - `context.json`: deterministic git-slop payload plus minimal report metadata.\n\
117             - `prompt.md`: prompt text for a local model.\n\
118             - `README.md`: these usage notes.\n\n\
119             Boundary rules:\n\n\
120             - This pack is advisory only.\n\
121             - Local model output must not mutate code, GitHub, or detector truth.\n\
122             - Local model output must not rescore detector truth, including\n\
123               `slop_score`, `slop_band`,\n\
124               `context_band`, or `git slop check` semantics.\n\
125             - Keep hotspot cost separate from overlay evidence.\n"
126        ),
127    )?;
128    Ok(())
129}