Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5749c447-6ecd-46bd-946d-21b4d101d084
269 lines
7.6 KiB
Go
269 lines
7.6 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/store"
|
|
)
|
|
|
|
// Atom document types (RFC 4287). Hand-rolled to avoid a feed dependency.
|
|
type atomFeed struct {
|
|
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
|
Title string `xml:"title"`
|
|
ID string `xml:"id"`
|
|
Updated string `xml:"updated"`
|
|
Links []atomLink `xml:"link"`
|
|
Author atomAuthor `xml:"author"`
|
|
Entries []atomEntry `xml:"entry"`
|
|
}
|
|
|
|
type atomLink struct {
|
|
Href string `xml:"href,attr"`
|
|
Rel string `xml:"rel,attr,omitempty"`
|
|
Type string `xml:"type,attr,omitempty"`
|
|
}
|
|
|
|
type atomAuthor struct {
|
|
Name string `xml:"name"`
|
|
}
|
|
|
|
type atomEntry struct {
|
|
Title string `xml:"title"`
|
|
ID string `xml:"id"`
|
|
Updated string `xml:"updated"`
|
|
Links []atomLink `xml:"link"`
|
|
Content atomContent `xml:"content"`
|
|
}
|
|
|
|
type atomContent struct {
|
|
Type string `xml:"type,attr"`
|
|
Body string `xml:",chardata"`
|
|
}
|
|
|
|
// renderAtom serializes entries into an Atom 1.0 document.
|
|
func renderAtom(cfg Config, entries []store.Entry) ([]byte, error) {
|
|
feedID := cfg.FeedID
|
|
if feedID == "" {
|
|
feedID = strings.TrimRight(cfg.BaseURL, "/") + "/feed.atom"
|
|
}
|
|
title := cfg.FeedTitle
|
|
if title == "" {
|
|
title = "jarvis alerts"
|
|
}
|
|
|
|
updated := time.Now().UTC()
|
|
if len(entries) > 0 {
|
|
updated = entries[0].CreatedAt.UTC()
|
|
}
|
|
|
|
f := atomFeed{
|
|
Title: title,
|
|
ID: feedID,
|
|
Updated: updated.Format(time.RFC3339),
|
|
Links: []atomLink{
|
|
{Href: feedID, Rel: "self", Type: "application/atom+xml"},
|
|
},
|
|
Author: atomAuthor{Name: "jarvis"},
|
|
}
|
|
|
|
for _, e := range entries {
|
|
f.Entries = append(f.Entries, atomEntry{
|
|
Title: e.Title,
|
|
ID: "urn:jarvis:entry:" + e.ID,
|
|
Updated: e.CreatedAt.UTC().Format(time.RFC3339),
|
|
Links: []atomLink{
|
|
{Href: feedID + "#" + e.ID, Rel: "alternate", Type: "text/html"},
|
|
},
|
|
Content: atomContent{Type: "html", Body: entryHTML(e)},
|
|
})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
buf.WriteString(xml.Header)
|
|
enc := xml.NewEncoder(&buf)
|
|
enc.Indent("", " ")
|
|
if err := enc.Encode(f); err != nil {
|
|
return nil, err
|
|
}
|
|
buf.WriteByte('\n')
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// entryHTML builds the (escaped) HTML body of a feed entry. The triage
|
|
// verdict/confidence are surfaced up top so a reader can decide act/watch/ignore
|
|
// at a glance, followed by the structured triage report. The raw agent
|
|
// investigation transcript is tucked into a collapsible <details> block below
|
|
// as supporting evidence. Everything is escaped so the reader can safely
|
|
// display it.
|
|
func entryHTML(e store.Entry) string {
|
|
var b strings.Builder
|
|
|
|
if e.Verdict != "" {
|
|
b.WriteString("<p><strong>Verdict: ")
|
|
b.WriteString(html.EscapeString(strings.ToUpper(e.Verdict)))
|
|
b.WriteString("</strong>")
|
|
if e.Confidence != "" {
|
|
b.WriteString(" <em>(confidence: ")
|
|
b.WriteString(html.EscapeString(e.Confidence))
|
|
b.WriteString(")</em>")
|
|
}
|
|
b.WriteString("</p>")
|
|
}
|
|
|
|
b.WriteString("<p><strong>")
|
|
b.WriteString(html.EscapeString(e.Summary))
|
|
b.WriteString("</strong></p>")
|
|
|
|
meta := []string{}
|
|
if e.Host != "" {
|
|
meta = append(meta, "host: "+html.EscapeString(e.Host))
|
|
}
|
|
if e.Severity != "" {
|
|
meta = append(meta, "severity: "+html.EscapeString(e.Severity))
|
|
}
|
|
if e.Status != "" {
|
|
meta = append(meta, "status: "+html.EscapeString(e.Status))
|
|
}
|
|
if len(meta) > 0 {
|
|
b.WriteString("<p><em>")
|
|
b.WriteString(strings.Join(meta, " · "))
|
|
b.WriteString("</em></p>")
|
|
}
|
|
|
|
triage, transcript := splitDiagnostic(e.Diagnostic)
|
|
b.WriteString(markdownToHTML(triage))
|
|
if transcript != "" {
|
|
// <details>/<summary> collapse in capable readers; miniflux 2.3.1
|
|
// strips those tags but keeps the children, so it degrades to a
|
|
// labelled <pre> block below the triage.
|
|
b.WriteString("<details><summary>Investigation transcript</summary><pre>")
|
|
b.WriteString(html.EscapeString(transcript))
|
|
b.WriteString("</pre></details>")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
var (
|
|
// triageStartRe marks where the structured triage begins: the first
|
|
// Verdict line. Everything before it is the agent's investigation
|
|
// transcript (tool calls, intermediate reasoning).
|
|
triageStartRe = regexp.MustCompile(`(?im)^[ \t]*[-*]?[ \t]*\**verdict\**[ \t]*:`)
|
|
// badgeLineRe matches a leading Verdict/Confidence line, already surfaced
|
|
// in the badge at the top, so it can be trimmed from the triage body.
|
|
badgeLineRe = regexp.MustCompile(`(?i)^[ \t]*[-*]?[ \t]*\**(verdict|confidence)\**[ \t]*:`)
|
|
)
|
|
|
|
// splitDiagnostic separates the structured triage (the tail beginning at the
|
|
// Verdict line) from the preceding agent transcript. The redundant leading
|
|
// Verdict/Confidence lines are trimmed from the triage since they are already
|
|
// shown in the badge. If no Verdict line is found the whole text is treated as
|
|
// the triage with no transcript.
|
|
func splitDiagnostic(diagnostic string) (triage, transcript string) {
|
|
loc := triageStartRe.FindStringIndex(diagnostic)
|
|
if loc == nil {
|
|
return strings.TrimSpace(diagnostic), ""
|
|
}
|
|
transcript = strings.TrimSpace(diagnostic[:loc[0]])
|
|
triage = trimBadgeLines(diagnostic[loc[0]:])
|
|
return triage, transcript
|
|
}
|
|
|
|
// trimBadgeLines drops leading blank and Verdict/Confidence lines from a triage
|
|
// body so they aren't repeated below the badge.
|
|
func trimBadgeLines(triage string) string {
|
|
lines := strings.Split(strings.ReplaceAll(triage, "\r\n", "\n"), "\n")
|
|
i := 0
|
|
for i < len(lines) {
|
|
t := strings.TrimSpace(lines[i])
|
|
if t == "" || badgeLineRe.MatchString(t) {
|
|
i++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines[i:], "\n"))
|
|
}
|
|
|
|
// markdownToHTML is a minimal, dependency-free markdown renderer covering the
|
|
// subset Copilot emits for these diagnostics: headings, bold, bullet lists,
|
|
// and paragraphs. Everything is HTML-escaped first.
|
|
func markdownToHTML(md string) string {
|
|
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
|
|
var b strings.Builder
|
|
inList := false
|
|
closeList := func() {
|
|
if inList {
|
|
b.WriteString("</ul>")
|
|
inList = false
|
|
}
|
|
}
|
|
for _, raw := range lines {
|
|
line := strings.TrimRight(raw, " \t")
|
|
trimmed := strings.TrimSpace(line)
|
|
switch {
|
|
case trimmed == "":
|
|
closeList()
|
|
case strings.HasPrefix(trimmed, "### "):
|
|
closeList()
|
|
b.WriteString("<h4>" + inline(trimmed[4:]) + "</h4>")
|
|
case strings.HasPrefix(trimmed, "## "):
|
|
closeList()
|
|
b.WriteString("<h3>" + inline(trimmed[3:]) + "</h3>")
|
|
case strings.HasPrefix(trimmed, "# "):
|
|
closeList()
|
|
b.WriteString("<h3>" + inline(trimmed[2:]) + "</h3>")
|
|
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "):
|
|
if !inList {
|
|
b.WriteString("<ul>")
|
|
inList = true
|
|
}
|
|
b.WriteString("<li>" + inline(trimmed[2:]) + "</li>")
|
|
default:
|
|
closeList()
|
|
b.WriteString("<p>" + inline(trimmed) + "</p>")
|
|
}
|
|
}
|
|
closeList()
|
|
return b.String()
|
|
}
|
|
|
|
// inline escapes text and renders **bold** and `code` spans.
|
|
func inline(s string) string {
|
|
s = html.EscapeString(s)
|
|
s = replacePairs(s, "**", "<strong>", "</strong>")
|
|
s = replacePairs(s, "`", "<code>", "</code>")
|
|
return s
|
|
}
|
|
|
|
// replacePairs turns matched pairs of delim into open/close tags. Unmatched
|
|
// trailing delimiters are left as literal text.
|
|
func replacePairs(s, delim, open, close string) string {
|
|
var b strings.Builder
|
|
openNext := true
|
|
for {
|
|
i := strings.Index(s, delim)
|
|
if i < 0 {
|
|
b.WriteString(s)
|
|
break
|
|
}
|
|
b.WriteString(s[:i])
|
|
if openNext {
|
|
b.WriteString(open)
|
|
} else {
|
|
b.WriteString(close)
|
|
}
|
|
openNext = !openNext
|
|
s = s[i+len(delim):]
|
|
}
|
|
// If we ended mid-pair, the last tag was an unmatched opener; fix by
|
|
// appending nothing — readers tolerate a stray <strong>, but to be safe
|
|
// we only emit balanced output above when pairs match. Acceptable for
|
|
// our controlled input.
|
|
return b.String()
|
|
}
|