Commit d8468848504b

Vincent Demeester <vincent@sbr.pm>
2026-04-15 17:08:16
feat: improve daily-plan github and jira integration
1 parent 6287d6f
Changed files (3)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -126,13 +126,15 @@ type jsonInbox struct {
 }
 
 type jsonAdvisory struct {
-	GHSAID   string `json:"ghsa_id"`
-	CVEID    string `json:"cve_id,omitempty"`
-	Summary  string `json:"summary"`
-	Severity string `json:"severity"`
-	State    string `json:"state"`
-	Repo     string `json:"repo"`
-	URL      string `json:"url"`
+	GHSAID   string   `json:"ghsa_id"`
+	CVEID    string   `json:"cve_id,omitempty"`
+	Summary  string   `json:"summary"`
+	Severity string   `json:"severity"`
+	State    string   `json:"state"`
+	Repo     string   `json:"repo"`
+	URL      string   `json:"url"`
+	Credits  []string `json:"credits,omitempty"`
+	Role     string   `json:"role"` // "author", "credited", or "collaborator"
 }
 
 type jsonDependabot struct {
@@ -363,6 +365,7 @@ func cmdInbox(ctx context.Context, cfg *config.Config, sinceStr string) error {
 			ja = append(ja, jsonAdvisory{
 				GHSAID: a.GHSAID, CVEID: a.CVEID, Summary: a.Summary,
 				Severity: a.Severity, State: a.State, Repo: a.Repo, URL: a.HTMLURL,
+				Credits: a.Credits, Role: a.Role,
 			})
 		}
 		jd := make([]jsonDependabot, 0, len(dependabot))
@@ -569,19 +572,20 @@ type jsonAI struct {
 }
 
 type jsonWeekly struct {
-	Week            string           `json:"week"`
-	NextMonday      string           `json:"next_monday"`
-	OrgDone         []jsonOrgDone    `json:"org_done,omitempty"`
-	AISessions      []jsonAI         `json:"ai_sessions,omitempty"`
-	JiraCompleted   []jsonJira       `json:"jira_completed"`
-	GHMerged        []jsonGH         `json:"github_merged"`
-	GHReviewed      []jsonGH         `json:"github_reviewed"`
-	GHIssuesCreated []jsonGH         `json:"github_issues_created"`
-	GHDiscussions   []jsonDiscussion `json:"github_discussions,omitempty"`
-	GHComments      []jsonComment    `json:"github_comments,omitempty"`
-	JiraInProgress  []jsonJira       `json:"jira_in_progress"`
-	JiraBacklog     []jsonJira       `json:"jira_backlog"`
-	GHIssues        []jsonGH         `json:"github_issues"`
+	Week               string           `json:"week"`
+	NextMonday         string           `json:"next_monday"`
+	OrgDone            []jsonOrgDone    `json:"org_done,omitempty"`
+	AISessions         []jsonAI         `json:"ai_sessions,omitempty"`
+	JiraCompleted      []jsonJira       `json:"jira_completed"`
+	GHMerged           []jsonGH         `json:"github_merged"`
+	GHReviewed         []jsonGH         `json:"github_reviewed"`
+	GHIssuesCreated    []jsonGH         `json:"github_issues_created"`
+	GHDiscussions      []jsonDiscussion `json:"github_discussions,omitempty"`
+	GHComments         []jsonComment    `json:"github_comments,omitempty"`
+	SecurityAdvisories []jsonAdvisory   `json:"github_security_advisories,omitempty"`
+	JiraInProgress     []jsonJira       `json:"jira_in_progress"`
+	JiraBacklog        []jsonJira       `json:"jira_backlog"`
+	GHIssues           []jsonGH         `json:"github_issues"`
 }
 
 func cmdWeekly(ctx context.Context, cfg *config.Config) error {
@@ -822,18 +826,38 @@ func cmdReview(ctx context.Context, cfg *config.Config, period string) error {
 		})
 	}
 
+	// Security advisories
+	advisories, _ := cache.GetOrFetch(apiCache, "gh:advisories:"+startStr, func() ([]github.SecurityAdvisory, error) {
+		return github.FetchSecurityAdvisoriesSince(ctx, cfg.GitHub.SecurityRepos, start, cfg.GitHub.Username)
+	})
+
 	if outputJSON {
+		var ja []jsonAdvisory
+		for _, a := range advisories {
+			ja = append(ja, jsonAdvisory{
+				GHSAID:   a.GHSAID,
+				CVEID:    a.CVEID,
+				Summary:  a.Summary,
+				Severity: a.Severity,
+				State:    a.State,
+				Repo:     a.Repo,
+				URL:      a.HTMLURL,
+				Credits:  a.Credits,
+				Role:     a.Role,
+			})
+		}
 		return emitJSON(jsonWeekly{
-			Week:            startStr,
-			NextMonday:      endStr,
-			OrgDone:         orgDoneToJSON(orgDone),
-			AISessions:      aiToJSON(aiItems),
-			JiraCompleted:   jiraToJSON(completed, cfg.Jira.BaseURL),
-			GHMerged:        ghToJSON(merged),
-			GHReviewed:      ghToJSON(reviewed),
-			GHIssuesCreated: ghToJSON(issuesCreated),
-			GHDiscussions:   discussionsToJSON(discussions),
-			GHComments:      commentsToJSON(comments),
+			Week:               startStr,
+			NextMonday:         endStr,
+			OrgDone:            orgDoneToJSON(orgDone),
+			AISessions:         aiToJSON(aiItems),
+			JiraCompleted:      jiraToJSON(completed, cfg.Jira.BaseURL),
+			GHMerged:           ghToJSON(merged),
+			GHReviewed:         ghToJSON(reviewed),
+			GHIssuesCreated:    ghToJSON(issuesCreated),
+			GHDiscussions:      discussionsToJSON(discussions),
+			GHComments:         commentsToJSON(comments),
+			SecurityAdvisories: ja,
 		})
 	}
 
@@ -916,6 +940,18 @@ func cmdReview(ctx context.Context, cfg *config.Config, period string) error {
 		fmt.Println()
 	}
 
+	if len(advisories) > 0 {
+		fmt.Printf("## Security Advisories (%d)\n\n", len(advisories))
+		for _, a := range advisories {
+			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.Println()
+	}
+
 	if len(aiItems) > 0 {
 		// Filter auto-recovered
 		var filtered []ai.Item
tools/daily-plan/internal/github/github.go
@@ -115,29 +115,37 @@ func FetchNewPRsSince(ctx context.Context, since time.Time, owners []string) ([]
 }
 
 // FetchMergedPRsSince returns PRs by user merged since the given date.
+// Splits the query by month to avoid GitHub Search API result limits.
 func FetchMergedPRsSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
-	args := []string{
-		"search", "prs",
-		"--author", username,
-		"--merged", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
-		"--limit", "30",
-		"--json", "repository,number,title,closedAt",
-	}
-	args = append(args, ownerFlags(owners)...)
-	return runGHSearch(ctx, args, "pr")
+	return fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
+		args := []string{
+			"search", "prs",
+			"--author", username,
+			"--merged",
+			"--merged-at", start + ".." + end,
+			"--limit", "1000",
+			"--json", "repository,number,title,closedAt",
+		}
+		args = append(args, ownerFlags(owners)...)
+		return runGHSearch(ctx, args, "pr")
+	})
 }
 
 // FetchReviewsGivenSince fetches PRs reviewed by the user since a given date.
+// Splits the query by month to avoid GitHub Search API result limits.
 func FetchReviewsGivenSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
-	args := []string{
-		"search", "prs",
-		"--reviewed-by", username,
-		"--updated", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
-		"--limit", "30",
-		"--json", "repository,number,title,author,createdAt,closedAt",
-	}
-	args = append(args, ownerFlags(owners)...)
-	items, err := runGHSearch(ctx, args, "pr")
+	items, err := fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
+		args := []string{
+			"search", "prs",
+			"--reviewed-by", username,
+			"--merged",
+			"--merged-at", start + ".." + end,
+			"--limit", "1000",
+			"--json", "repository,number,title,author,createdAt,closedAt",
+		}
+		args = append(args, ownerFlags(owners)...)
+		return runGHSearch(ctx, args, "pr")
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -152,16 +160,19 @@ func FetchReviewsGivenSince(ctx context.Context, username string, since time.Tim
 }
 
 // FetchIssuesCreatedSince returns issues created by user since the given date.
+// Splits the query by month to avoid GitHub Search API result limits.
 func FetchIssuesCreatedSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
-	args := []string{
-		"search", "issues",
-		"--author", username,
-		"--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
-		"--limit", "30",
-		"--json", "repository,number,title,createdAt,author",
-	}
-	args = append(args, ownerFlags(owners)...)
-	return runGHSearch(ctx, args, "issue")
+	return fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
+		args := []string{
+			"search", "issues",
+			"--author", username,
+			"--created", start + ".." + end,
+			"--limit", "1000",
+			"--json", "repository,number,title,createdAt,author",
+		}
+		args = append(args, ownerFlags(owners)...)
+		return runGHSearch(ctx, args, "issue")
+	})
 }
 
 // DiscussionItem represents a GitHub discussion or discussion comment.
@@ -412,6 +423,8 @@ type SecurityAdvisory struct {
 	HTMLURL   string    `json:"html_url"`
 	Repo      string    `json:"-"` // filled in by caller
 	CreatedAt time.Time `json:"created_at"`
+	Credits   []string  `json:"-"` // credit logins, filled from raw response
+	Role      string    `json:"-"` // "credited", "collaborator", "author"
 }
 
 // FetchSecurityAdvisories fetches open/triage security advisories for specific repos.
@@ -433,6 +446,27 @@ func FetchSecurityAdvisories(ctx context.Context, repos []string, states []strin
 	return all, nil
 }
 
+// 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 {
+		Login string `json:"login"`
+	} `json:"author"`
+	Credits []struct {
+		Login string `json:"login"`
+	} `json:"credits"`
+	CollaboratingUsers []struct {
+		Login string `json:"login"`
+	} `json:"collaborating_users"`
+}
+
 func fetchRepoAdvisories(ctx context.Context, repo, state string) ([]SecurityAdvisory, error) {
 	endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
 	cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
@@ -443,13 +477,49 @@ func fetchRepoAdvisories(ctx context.Context, repo, state string) ([]SecurityAdv
 		return nil, fmt.Errorf("gh api error: %s", stderr.String())
 	}
 
-	var advisories []SecurityAdvisory
-	if err := json.Unmarshal(stdout.Bytes(), &advisories); err != nil {
+	var raw []advisoryRaw
+	if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
 		return nil, err
 	}
+
+	advisories := make([]SecurityAdvisory, 0, len(raw))
+	for _, r := range raw {
+		var credits []string
+		for _, c := range r.Credits {
+			credits = append(credits, c.Login)
+		}
+		a := SecurityAdvisory{
+			GHSAID:    r.GHSAID,
+			CVEID:     r.CVEID,
+			Summary:   r.Summary,
+			Severity:  r.Severity,
+			State:     r.State,
+			HTMLURL:   r.HTMLURL,
+			CreatedAt: r.CreatedAt,
+			Credits:   credits,
+		}
+		advisories = append(advisories, a)
+	}
 	return advisories, nil
 }
 
+// userInvolved checks if a username appears in credits or as author.
+// We intentionally skip collaborating_users because that only indicates
+// access (e.g. via team membership like tekton-vmt), not active involvement.
+// Comments on advisories live in a private fork and are not accessible
+// via the REST API, so we cannot detect comment-based involvement.
+func userInvolved(raw advisoryRaw, username string) string {
+	if raw.Author.Login == username {
+		return "author"
+	}
+	for _, c := range raw.Credits {
+		if c.Login == username {
+			return "credited"
+		}
+	}
+	return ""
+}
+
 // DependabotAlert represents a Dependabot security alert.
 type DependabotAlert struct {
 	Number    int       `json:"number"`
@@ -520,6 +590,97 @@ func FetchDependabotAlerts(ctx context.Context, owners []string, severity string
 	return all, nil
 }
 
+// fetchByMonth splits a date range into monthly chunks and runs the given
+// query function for each chunk, deduplicating results by repo+number.
+// This avoids the GitHub Search API's 1000-result-per-query limit.
+func fetchByMonth(ctx context.Context, since time.Time, queryFn func(start, end string) ([]Item, error)) ([]Item, error) {
+	now := time.Now()
+	var all []Item
+	seen := make(map[string]bool)
+
+	current := since
+	for current.Before(now) {
+		// End of this chunk: last day of the month or now, whichever is earlier
+		nextMonth := time.Date(current.Year(), current.Month()+1, 1, 0, 0, 0, 0, current.Location())
+		end := nextMonth.AddDate(0, 0, -1) // last day of current month
+		if end.After(now) {
+			end = now
+		}
+
+		startStr := current.Format("2006-01-02")
+		endStr := end.Format("2006-01-02")
+
+		items, err := queryFn(startStr, endStr)
+		if err != nil {
+			// Continue on error — partial data is better than none
+			current = nextMonth
+			continue
+		}
+
+		for _, item := range items {
+			key := fmt.Sprintf("%s#%d", item.Repo, item.Number)
+			if !seen[key] {
+				seen[key] = true
+				all = append(all, item)
+			}
+		}
+
+		current = nextMonth
+	}
+	return all, nil
+}
+
+// FetchSecurityAdvisoriesSince fetches security advisories created since the
+// given date where the specified user is involved (credited, collaborating, or author).
+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)
+			var stdout, stderr bytes.Buffer
+			cmd.Stdout = &stdout
+			cmd.Stderr = &stderr
+			if err := cmd.Run(); err != nil {
+				continue
+			}
+
+			var raw []advisoryRaw
+			if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
+				continue
+			}
+
+			for _, r := range raw {
+				if r.CreatedAt.Before(since) {
+					continue
+				}
+				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,
+				})
+			}
+		}
+	}
+	return all, nil
+}
+
 func ownerFlags(owners []string) []string {
 	var flags []string
 	for _, owner := range owners {
tools/daily-plan/internal/jira/jira.go
@@ -57,23 +57,19 @@ func FetchSecuritySince(ctx context.Context, since time.Time) ([]Issue, error) {
 }
 
 // FetchCompletedSince fetches issues resolved by user since the given date.
+// Uses JQL with resolutiondate to get only issues actually resolved in the
+// period, not merely updated while in a Done/Closed state.
 func FetchCompletedSince(ctx context.Context, user string, since time.Time) ([]Issue, error) {
-	var allIssues []Issue
-	for _, status := range []string{"Done", "Closed"} {
-		sinceStr := since.Format("2006-01-02")
-		args := []string{
-			"issue", "list", "--plain", "--no-truncate",
-			"-a", jiraMe(),
-			"-s", status,
-			"--updated-after", sinceStr,
-		}
-		issues, err := runJiraList(ctx, args)
-		if err != nil {
-			continue
-		}
-		allIssues = append(allIssues, issues...)
+	sinceStr := since.Format("2006-01-02")
+	jql := fmt.Sprintf(
+		`assignee = currentUser() AND statusCategory = Done AND resolution not in ("Obsolete", "Won't Do", "Won't Fix", "Duplicate") AND resolutiondate >= "%s"`,
+		sinceStr,
+	)
+	args := []string{
+		"issue", "list", "--plain", "--no-truncate",
+		"-q", jql,
 	}
-	return allIssues, nil
+	return runJiraList(ctx, args)
 }
 
 // FetchSummary fetches the summary for a single issue key.