Commit 3918251f4747

Vincent Demeester <vincent@sbr.pm>
2026-06-30 09:57:00
feat(daily-plan): capture triaged security advisories (VMT review queue)
The advisory fetch previously (1) capped results at per_page=30 with no pagination, (2) filtered on created_at only, dropping advisories opened before the period but published/closed/triaged within it, and (3) only kept advisories where the user was credited or author. Now it: - paginates with --paginate --slurp and per_page=100 - considers an advisory active if created/published/closed/updated in window - tags every advisory with a Role: author, credited, or triaged (the security repos are VMT repos, so each advisory is reviewed even without an API-visible comment trail) - groups review markdown into Credited/Authored vs Triaged sections Also fixes cache loss of Repo/Credits/Role (were json:"-", stripped on cache round-trip) by giving them real json tags; user-facing JSON uses jsonAdvisory. Q2 2026: 6 credited/authored + 18 triaged across pipeline/triggers/chains/PaC (previously only 2 surfaced).
1 parent 7c01a45
Changed files (3)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -957,15 +957,37 @@ func cmdReview(ctx context.Context, cfg *config.Config, period string) error {
 	}
 
 	if len(advisories) > 0 {
-		fmt.Printf("## Security Advisories (%d)\n\n", len(advisories))
+		var involved, triaged []github.SecurityAdvisory
 		for _, a := range advisories {
+			if a.Role == "triaged" {
+				triaged = append(triaged, a)
+			} else {
+				involved = append(involved, a)
+			}
+		}
+		fmt.Printf("## Security Advisories (%d — %d credited/authored, %d triaged)\n\n",
+			len(advisories), len(involved), len(triaged))
+		printAdvisory := func(a github.SecurityAdvisory) {
 			cve := ""
 			if a.CVEID != "" {
 				cve = fmt.Sprintf(" (%s)", a.CVEID)
 			}
-			fmt.Printf("- [%s] **%s** %s%s — %s\n", a.State, a.GHSAID, a.Summary, cve, a.Repo)
+			fmt.Printf("- [%s] **%s** %s%s — %s _(%s)_\n", a.State, a.GHSAID, a.Summary, cve, a.Repo, a.Role)
+		}
+		if len(involved) > 0 {
+			fmt.Print("### Credited / Authored\n\n")
+			for _, a := range involved {
+				printAdvisory(a)
+			}
+			fmt.Println()
+		}
+		if len(triaged) > 0 {
+			fmt.Print("### Triaged (VMT review queue)\n\n")
+			for _, a := range triaged {
+				printAdvisory(a)
+			}
+			fmt.Println()
 		}
-		fmt.Println()
 	}
 
 	if len(aiItems) > 0 {
tools/daily-plan/internal/display/display.go
@@ -166,8 +166,8 @@ func SecurityAdvisories(advisories []github.SecurityAdvisory) {
 		if len(summary) > 60 {
 			summary = summary[:57] + "..."
 		}
-		fmt.Printf("  %s⚠ %-20s%s %-60s %s[%s, %s]%s\n",
-			sevColor, ghsa, reset, summary, dim, a.Severity, a.Repo, reset)
+		fmt.Printf("  %s⚠ %-20s%s %-60s %s[%s, %s, %s]%s\n",
+			sevColor, ghsa, reset, summary, dim, a.Severity, a.Repo, a.Role, reset)
 	}
 }
 
tools/daily-plan/internal/github/github.go
@@ -434,10 +434,10 @@ type SecurityAdvisory struct {
 	Severity  string    `json:"severity"`
 	State     string    `json:"state"`
 	HTMLURL   string    `json:"html_url"`
-	Repo      string    `json:"-"` // filled in by caller
+	Repo      string    `json:"repo"`    // filled in by caller; persisted for cache
 	CreatedAt time.Time `json:"created_at"`
-	Credits   []string  `json:"-"` // credit logins, filled from raw response
-	Role      string    `json:"-"` // "credited", "collaborator", "author"
+	Credits   []string  `json:"credits"` // credit logins, filled from raw response
+	Role      string    `json:"role"`    // "author", "credited", or "triaged"
 }
 
 // FetchSecurityAdvisories fetches open/triage security advisories for specific repos.
@@ -462,14 +462,17 @@ func FetchSecurityAdvisories(ctx context.Context, repos []string, states []strin
 // advisoryRaw is the raw JSON shape from the GitHub Security Advisories API,
 // including credits and collaborating_users for involvement filtering.
 type advisoryRaw struct {
-	GHSAID    string    `json:"ghsa_id"`
-	CVEID     string    `json:"cve_id"`
-	Summary   string    `json:"summary"`
-	Severity  string    `json:"severity"`
-	State     string    `json:"state"`
-	HTMLURL   string    `json:"html_url"`
-	CreatedAt time.Time `json:"created_at"`
-	Author    struct {
+	GHSAID      string     `json:"ghsa_id"`
+	CVEID       string     `json:"cve_id"`
+	Summary     string     `json:"summary"`
+	Severity    string     `json:"severity"`
+	State       string     `json:"state"`
+	HTMLURL     string     `json:"html_url"`
+	CreatedAt   time.Time  `json:"created_at"`
+	PublishedAt *time.Time `json:"published_at"`
+	ClosedAt    *time.Time `json:"closed_at"`
+	UpdatedAt   *time.Time `json:"updated_at"`
+	Author      struct {
 		Login string `json:"login"`
 	} `json:"author"`
 	Credits []struct {
@@ -533,6 +536,31 @@ func userInvolved(raw advisoryRaw, username string) string {
 	return ""
 }
 
+// userInvolvedOrTriaged returns the user's role for an advisory. If the user is
+// not the author or credited, it returns "triaged" — because the configured
+// security repos are VMT repos, every advisory in them is reviewed as part of
+// the security queue even when the API exposes no per-user comment trail.
+func userInvolvedOrTriaged(raw advisoryRaw, username string) string {
+	if role := userInvolved(raw, username); role != "" {
+		return role
+	}
+	return "triaged"
+}
+
+// advisoryActiveInWindow reports whether an advisory was created, published,
+// closed, or updated on or after since. The previous logic only checked
+// created_at, which dropped advisories opened before the period but triaged,
+// published, or closed within it.
+func advisoryActiveInWindow(raw advisoryRaw, since time.Time) bool {
+	candidates := []*time.Time{&raw.CreatedAt, raw.PublishedAt, raw.ClosedAt, raw.UpdatedAt}
+	for _, t := range candidates {
+		if t != nil && !t.IsZero() && !t.Before(since) {
+			return true
+		}
+	}
+	return false
+}
+
 // DependabotAlert represents a Dependabot security alert.
 type DependabotAlert struct {
 	Number    int       `json:"number"`
@@ -643,15 +671,22 @@ func fetchByMonth(ctx context.Context, since time.Time, queryFn func(start, end
 	return all, nil
 }
 
-// FetchSecurityAdvisoriesSince fetches security advisories created since the
-// given date where the specified user is involved (credited, collaborating, or author).
+// FetchSecurityAdvisoriesSince fetches security advisories that were active
+// (created, published, closed, or updated) since the given date across the
+// configured repos. Each advisory is tagged with the user's Role:
+//   - "author"   = the user opened the advisory
+//   - "credited" = the user is in the credits list
+//   - "triaged"  = neither, but the repo is a VMT repo so the advisory was
+//     reviewed as part of the security queue
+//
+// Uses --paginate with per_page=100 to avoid silently truncating high-volume
+// repos (the previous per_page=30 cap dropped results for tektoncd/pipeline).
 func FetchSecurityAdvisoriesSince(ctx context.Context, repos []string, since time.Time, username string) ([]SecurityAdvisory, error) {
 	var all []SecurityAdvisory
 	for _, repo := range repos {
 		for _, state := range []string{"published", "closed", "draft", "triage"} {
-			// Fetch raw to check involvement
-			endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
-			cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
+			endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=100", repo, state)
+			cmd := exec.CommandContext(ctx, "gh", "api", "--paginate", "--slurp", endpoint)
 			var stdout, stderr bytes.Buffer
 			cmd.Stdout = &stdout
 			cmd.Stderr = &stderr
@@ -659,35 +694,40 @@ func FetchSecurityAdvisoriesSince(ctx context.Context, repos []string, since tim
 				continue
 			}
 
-			var raw []advisoryRaw
-			if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
-				continue
+			// --slurp wraps paginated pages in an outer array: [[...],[...]].
+			var pages [][]advisoryRaw
+			if err := json.Unmarshal(stdout.Bytes(), &pages); err != nil {
+				// Fall back to a single un-slurped array.
+				var single []advisoryRaw
+				if err2 := json.Unmarshal(stdout.Bytes(), &single); err2 != nil {
+					continue
+				}
+				pages = [][]advisoryRaw{single}
 			}
 
-			for _, r := range raw {
-				if r.CreatedAt.Before(since) {
-					continue
+			for _, page := range pages {
+				for _, r := range page {
+					if !advisoryActiveInWindow(r, since) {
+						continue
+					}
+					role := userInvolvedOrTriaged(r, username)
+					var credits []string
+					for _, c := range r.Credits {
+						credits = append(credits, c.Login)
+					}
+					all = append(all, SecurityAdvisory{
+						GHSAID:    r.GHSAID,
+						CVEID:     r.CVEID,
+						Summary:   r.Summary,
+						Severity:  r.Severity,
+						State:     r.State,
+						HTMLURL:   r.HTMLURL,
+						CreatedAt: r.CreatedAt,
+						Repo:      repo,
+						Credits:   credits,
+						Role:      role,
+					})
 				}
-				role := userInvolved(r, username)
-				if role == "" {
-					continue
-				}
-				var credits []string
-				for _, c := range r.Credits {
-					credits = append(credits, c.Login)
-				}
-				all = append(all, SecurityAdvisory{
-					GHSAID:    r.GHSAID,
-					CVEID:     r.CVEID,
-					Summary:   r.Summary,
-					Severity:  r.Severity,
-					State:     r.State,
-					HTMLURL:   r.HTMLURL,
-					CreatedAt: r.CreatedAt,
-					Repo:      repo,
-					Credits:   credits,
-					Role:      role,
-				})
 			}
 		}
 	}