From aacaf795fb147e4592b7684d547fac8209f37eff Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 30 Oct 2026 12:37:03 +0100 Subject: [PATCH] Make since optional without comments and count the sub-issues of each issue as its duplicates --- action.yml | 2 +- cmd/tamis/main.go | 19 ++++++++++--------- cmd/tamis/main_test.go | 3 ++- docs/configuration.md | 2 +- docs/setup.md | 2 +- internal/board/board.go | 8 +++++++- internal/board/board_test.go | 21 +++++++++++++++++++++ 7 files changed, 43 insertions(+), 14 deletions(-) diff --git a/action.yml b/action.yml index 8527c1d..379efed 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: since: - description: Only advisories created after this date (2026-10-22 or RFC 3339) are triaged. Required unless `advisory` is set. + description: Only advisories created after this date (2026-10-22 or RFC 3339) are triaged. Required with `comment: true` unless `advisory` is set, every advisory in triage otherwise. required: false advisory: description: Triage this GHSA only, whatever its state and creation date. diff --git a/cmd/tamis/main.go b/cmd/tamis/main.go index 359b97d..f7ae00f 100644 --- a/cmd/tamis/main.go +++ b/cmd/tamis/main.go @@ -63,8 +63,16 @@ func run(ctx context.Context, log *slog.Logger) error { if err != nil { return err } - if since.IsZero() && cfg.Advisory == "" { - return errors.New("SINCE is required unless ADVISORY is set, so that existing advisories are left alone") + comment := false + if v := os.Getenv("COMMENT"); v != "" { + if comment, err = strconv.ParseBool(v); err != nil { + return fmt.Errorf("COMMENT: %w", err) + } + } + cfg.NoComment = !comment + // Without comments, triaging the backlog only fills the private board, so every advisory in triage goes. + if since.IsZero() && cfg.Advisory == "" && comment { + return errors.New("SINCE is required with COMMENT unless ADVISORY is set, so that existing advisories are left alone") } cfg.Since = since @@ -86,13 +94,6 @@ func run(ctx context.Context, log *slog.Logger) error { if err != nil { return err } - comment := false - if v := os.Getenv("COMMENT"); v != "" { - if comment, err = strconv.ParseBool(v); err != nil { - return fmt.Errorf("COMMENT: %w", err) - } - } - cfg.NoComment = !comment daybreak := false if v := os.Getenv("DAYBREAK"); v != "" { if daybreak, err = strconv.ParseBool(v); err != nil { diff --git a/cmd/tamis/main_test.go b/cmd/tamis/main_test.go index 5b119ec..d77a7cf 100644 --- a/cmd/tamis/main_test.go +++ b/cmd/tamis/main_test.go @@ -59,7 +59,8 @@ func TestRunValidatesConfiguration(t *testing.T) { env map[string]string want string }{ - "since required": {map[string]string{"REPOSITORY": "o/r", "GH_TOKEN": "t", "LLM_API_KEY": "k"}, "SINCE is required"}, + "since required": {map[string]string{"REPOSITORY": "o/r", "GH_TOKEN": "t", "LLM_API_KEY": "k", "COMMENT": "true"}, "SINCE is required with COMMENT"}, + "since optional": {map[string]string{"REPOSITORY": "o/r", "GH_TOKEN": "t", "LLM_API_KEY": "k"}, "OUTPUT_DIR is required"}, "token required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "LLM_API_KEY": "k"}, "GH_TOKEN is required"}, "api key required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t"}, "LLM_API_KEY is required"}, "invalid daybreak": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "DAYBREAK": "maybe"}, "DAYBREAK"}, diff --git a/docs/configuration.md b/docs/configuration.md index 8a78e08..9ec9439 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,7 @@ | `api-key` | | Required. API key of the model provider, or a Codex access token with `engine: openai`. | | `github-token` | | Required. Token able to read and comment the repository advisories. | | `references` | | Required, at most ten. Whitespace-separated local git references, one per supported branch. tamis analyses the first and locates the cited code on all of them. A missing reference fails the analysis. | -| `since` | | Triage only advisories created after this date, `2026-10-22` or RFC 3339. Required unless `advisory` is set. | +| `since` | | Triage only advisories created after this date, `2026-10-22` or RFC 3339. Required with `comment: true` unless `advisory` is set. Without it, every advisory in triage goes on the board. | | `advisory` | | Triage this GHSA only, whatever its state and creation date. | | `bot-login` | Owner of the token | Login tamis posts as, such as `my-app[bot]`. Required with a GitHub App token. | | `repository` | Current repository | Repository owning the advisories. | diff --git a/docs/setup.md b/docs/setup.md index c3c323b..088655e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -83,7 +83,7 @@ The code sets `Priority`: - `Duplicate` behind the oldest high-confidence exact duplicate the comment shows, which `Duplicate of` names, - `Analysis pending` while tamis triages again an advisory it commented before the board, `Analysis failed, retrying` after a failed attempt with another to come, and `Analysis failed` once tamis gives up. The draft body then explains the status instead of holding the triage comment. -The model estimates `Severity` from its CVSS vector and ignores the one the reporter claims. `Duplicates` counts the items that name the advisory in `Duplicate of`. `Component` is the directory of the extension, SAPI or subsystem the model names, such as `sapi/fpm` for FastCGI code a report reaches through FPM, once tamis checks the repository has that directory. Without one, tamis takes the directory of the faulty statement two levels deep, and leaves it empty when the statement was not located. `Original report` holds the oldest report of a group of duplicates, on that report and on each of its duplicates, so a view grouped by it shows each original with its duplicates. +The model estimates `Severity` from its CVSS vector and ignores the one the reporter claims. `Duplicates` counts the items that name the advisory in `Duplicate of`, or with `issues-repository` the sub-issues its issue gets, the duplicates of its duplicates included. `Component` is the directory of the extension, SAPI or subsystem the model names, such as `sapi/fpm` for FastCGI code a report reaches through FPM, once tamis checks the repository has that directory. Without one, tamis takes the directory of the faulty statement two levels deep, and leaves it empty when the statement was not located. `Original report` holds the oldest report of a group of duplicates, on that report and on each of its duplicates, so a view grouped by it shows each original with its duplicates. At the end of each run, tamis copies the state of every advisory to `State`, recounts `Duplicates` and ranks every triage again from `Severity`, `Policy exclusion`, `Located` and `Duplicate of`, so a new priority rule reaches the board with a release. None of it calls the model. A `Priority` changed by hand comes back at the next run, while a change to one of those four fields lasts. The REST API cannot archive items, so filter the views on `State` instead, for example a table on `State:triage,draft` sorted by `Priority` then `Reported`. diff --git a/internal/board/board.go b/internal/board/board.go index f4e109b..2e65fff 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -579,10 +579,16 @@ func (b *Board) Sync(ctx context.Context, advisories []github.Advisory) error { for _, a := range advisories { states[strings.ToLower(a.GHSAID)] = a.State } + // With issues, Duplicates counts the sub-issues an item gets, the whole chain under it included. counts := map[string]int{} for _, it := range b.items { - if it.duplicateOf != "" { + switch { + case b.issues == "" && it.duplicateOf != "": counts[strings.ToLower(it.duplicateOf)]++ + case b.issues != "" && it.issue != 0: + if parent := b.nest(it); parent != nil { + counts[strings.ToLower(parent.ghsa)]++ + } } } diff --git a/internal/board/board_test.go b/internal/board/board_test.go index 898c81a..c8b0979 100644 --- a/internal/board/board_test.go +++ b/internal/board/board_test.go @@ -744,3 +744,24 @@ func TestSyncGathersTheDuplicatesOfAClosedOriginalUnderTheOldestOfThem(t *testin require.NoError(t, b.Sync(t.Context(), nil)) require.Equal(t, writes, p.Writes, "a nested group was written again") } + +func TestSyncCountsTheSubIssuesOfEachIssue(t *testing.T) { + p := boardtest.New() + b, err := Open(t.Context(), p, p, "php/16", "acme/private-issues", "php/php-src") + require.NoError(t, err) + put := func(g, dup string, day int) { + a := github.Advisory{GHSAID: g, Summary: g, State: "triage", CreatedAt: reported.AddDate(0, 0, day)} + require.NoError(t, b.Put(t.Context(), a, Duplicate, &Triage{DuplicateOf: dup}, "")) + } + put("GHSA-root", "", 0) + put("GHSA-mid", "GHSA-root", 1) + put("GHSA-leaf", "GHSA-mid", 2) + put("GHSA-first", "GHSA-closed", 0) + put("GHSA-later", "GHSA-closed", 1) + + require.NoError(t, b.Sync(t.Context(), nil)) + + for g, want := range map[string]string{"GHSA-root": "2", "GHSA-mid": "", "GHSA-leaf": "", "GHSA-first": "1", "GHSA-later": ""} { + require.Equal(t, want, p.Value(p.Find(g), "Duplicates"), "%s does not count its sub-issues", g) + } +}