diff --git a/cmd/tamis/main.go b/cmd/tamis/main.go index 651ccf3..a48556f 100644 --- a/cmd/tamis/main.go +++ b/cmd/tamis/main.go @@ -180,6 +180,11 @@ func run(ctx context.Context, log *slog.Logger) error { } } cfg.NoComment = !comment + if v := os.Getenv("FORCE"); v != "" { + if cfg.Force, err = strconv.ParseBool(v); err != nil { + return fmt.Errorf("FORCE: %w", err) + } + } // 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") diff --git a/cmd/tamis/main_test.go b/cmd/tamis/main_test.go index d08f374..fee3d24 100644 --- a/cmd/tamis/main_test.go +++ b/cmd/tamis/main_test.go @@ -70,11 +70,12 @@ func TestRunValidatesConfiguration(t *testing.T) { "output required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k"}, "OUTPUT_DIR is required"}, "issues token required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "ISSUES_REPOSITORY": "o/r-security"}, "ISSUES_REPOSITORY needs an ISSUES_TOKEN of its own"}, "issues token reused": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "ISSUES_REPOSITORY": "o/r-security", "ISSUES_TOKEN": "t"}, "ISSUES_REPOSITORY needs an ISSUES_TOKEN of its own"}, + "invalid force": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "FORCE": "maybe"}, "FORCE"}, "invalid comment": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "COMMENT": "maybe"}, "COMMENT"}, "references required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "OUTPUT_DIR": t.TempDir()}, "REFERENCES must list"}, } { t.Run(name, func(t *testing.T) { - for _, k := range []string{"REPOSITORY", "GITHUB_REPOSITORY", "ADVISORY", "REPO_PATH", "BOT_LOGIN", "DAYBREAK", "SINCE", "GH_TOKEN", "ENGINE", "MODEL", "LLM_API_KEY", "OUTPUT_DIR", "REFERENCES", "PROJECT", "ISSUES_REPOSITORY", "ISSUES_TOKEN", "COMMENT", "PACKS"} { + for _, k := range []string{"REPOSITORY", "GITHUB_REPOSITORY", "ADVISORY", "REPO_PATH", "BOT_LOGIN", "DAYBREAK", "SINCE", "GH_TOKEN", "ENGINE", "MODEL", "LLM_API_KEY", "OUTPUT_DIR", "REFERENCES", "PROJECT", "ISSUES_REPOSITORY", "ISSUES_TOKEN", "COMMENT", "PACKS", "FORCE"} { t.Setenv(k, tc.env[k]) } diff --git a/docs/configuration.md b/docs/configuration.md index cf27da2..f3f8c68 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,8 @@ The action refuses to run from a public workflow repository or from a `uses:` th Outside GitHub Actions, tamis reads the same settings from environment variables: `LLM_API_KEY`, `GH_TOKEN`, `BOT_LOGIN`, `REPOSITORY`, `REPO_PATH`, `SINCE`, `ADVISORY`, `ENGINE`, `MODEL`, `DAYBREAK`, `RUNNER_IMAGE`, `REFERENCES`, `PROJECT`, `ISSUES_REPOSITORY`, `ISSUES_TOKEN`, `COMMENT` and `PACKS`. `OUTPUT_DIR` must name a private directory for the reports. The action creates it in the runner's temporary storage and uploads it to the workflow repository with a seven-day retention. +A local run alone also reads `FORCE=true`, which triages every selected advisory again, `ADVISORY` alone or every one in triage, and updates its board item even when it already holds a triage. It needs `COMMENT` off, and a failed new triage leaves the previous one on the board. The action has no such input. + ## Source branches Fetch the references with `fetch-depth: 0`. For php-src, list the default branch and each PHP branch your disclosure policy covers, such as `master origin/PHP-8.4`, and update the list as releases enter or leave support. diff --git a/internal/triage/triage.go b/internal/triage/triage.go index 90343d3..6b97d5f 100644 --- a/internal/triage/triage.go +++ b/internal/triage/triage.go @@ -82,6 +82,8 @@ type Config struct { Issues board.Issues // Packs names the built-in guidance packs appended to the reader's method, such as php-src. Packs []string + // Force triages every selected advisory again for the board, even one its item already holds a triage for. + Force bool // guidance is the text of Packs, which Run reads once. guidance string } @@ -125,6 +127,9 @@ func Run(ctx context.Context, cfg Config, gh GitHub, reader Reader, eng engine.E if cfg.NoComment && cfg.Project == "" { return errors.New("a run without comments needs a project board to record its triages") } + if cfg.Force && !cfg.NoComment { + return errors.New("a forced run triages again for the board only, so it needs comments off") + } guidance, err := skill.Packs(cfg.Packs) if err != nil { return err @@ -197,6 +202,11 @@ func Run(ctx context.Context, cfg Config, gh GitHub, reader Reader, eng engine.E done, failures = priority != "" && priority != board.AnalysisRetrying && priority != board.AnalysisPending, n backfill = !done } + // The triage an item already holds stays on the board until a new one replaces it. + keep := cfg.Force && done + if cfg.Force { + done, backfill, failures = false, true, 0 + } if done && !backfill || failures >= maxAttempts { cancel() log.Info("skipped, already triaged or out of attempts") @@ -221,7 +231,7 @@ func Run(ctx context.Context, cfg Config, gh GitHub, reader Reader, eng engine.E } // The item goes on the board first, so a board refusing writes costs no model run. A failed triage for the // board is not retried, so a report that never completes costs one run only. - if backfill { + if backfill && !keep { note := fmt.Sprintf("The automated analysis started on %s UTC and has not finished. If this stays after the next run, that run stopped during the analysis.", time.Now().UTC().Format("2006-01-02 at 15:04")) if err := b.PutStatus(advisoryCtx, a, board.AnalysisPending, note, failures+1); err != nil { cancel() @@ -245,7 +255,7 @@ func Run(ctx context.Context, cfg Config, gh GitHub, reader Reader, eng engine.E if !published && !backfill { _ = gh.PostComment(ctx, cfg.Repo, a.GHSAID, report.Failed(!final)) } - if b != nil && !published { + if b != nil && !published && !keep { priority, note := board.AnalysisFailed, fmt.Sprintf("The automated analysis failed (%s) and will not be retried. A maintainer must triage this report.", describe(err)) if !final { priority, note = board.AnalysisRetrying, fmt.Sprintf("The automated analysis failed (%s) on attempt %d of %d. The next run retries it.", describe(err), failures+1, maxAttempts) diff --git a/internal/triage/triage_test.go b/internal/triage/triage_test.go index d15f690..93c3e2d 100644 --- a/internal/triage/triage_test.go +++ b/internal/triage/triage_test.go @@ -1637,6 +1637,46 @@ func TestRunWithoutCommentsRetriesAnAnalysisAStoppedRunLeftPending(t *testing.T) require.Empty(t, gh.posted) } +func TestRunForcedTriagesAgainAnAdvisoryItsItemHoldsATriageFor(t *testing.T) { + gh, eng := newFixture(), &fakeEngine{} + id := gh.Add(map[string]any{"GHSA": "GHSA-new", "State": "triage", "Priority": "Look first", "Severity": "Critical", "Policy exclusion": "No", "Located": "Yes"}) + + require.NoError(t, run(t, Config{Advisory: "GHSA-new", Project: "acme/16", NoComment: true, Force: true}, gh, eng)) + + require.NotEmpty(t, eng.role(""), "a forced run skipped a triaged advisory") + require.Equal(t, "Duplicate", gh.Value(id, "Priority"), "the new triage did not replace the old one") + require.Empty(t, gh.posted) +} + +func TestRunForcedKeepsTheTriageAnItemHoldsWhenTheNewOneFails(t *testing.T) { + gh, eng := newFixture(), &fakeEngine{fail: "GHSA-new"} + id := gh.Add(map[string]any{"GHSA": "GHSA-new", "State": "triage", "Priority": "Look first", "Severity": "Critical", "Policy exclusion": "No", "Located": "Yes"}) + + require.Error(t, run(t, Config{Advisory: "GHSA-new", Project: "acme/16", NoComment: true, Force: true}, gh, eng)) + + require.NotEmpty(t, eng.role("")) + require.Equal(t, "Look first", gh.Value(id, "Priority"), "a failed forced triage erased the previous one") + require.Equal(t, "Critical", gh.Value(id, "Severity")) +} + +func TestRunForcedTriagesAgainAnAdvisoryOutOfAttempts(t *testing.T) { + gh, eng := newFixture(), &fakeEngine{} + id := gh.Add(map[string]any{"GHSA": "GHSA-new", "State": "triage", "Priority": "Analysis failed", "Attempts": 3}) + + require.NoError(t, run(t, Config{Advisory: "GHSA-new", Project: "acme/16", NoComment: true, Force: true}, gh, eng)) + + require.NotEmpty(t, eng.role(""), "a forced run skipped an advisory out of attempts") + require.Equal(t, "Duplicate", gh.Value(id, "Priority")) +} + +func TestRunRefusesToForceWithComments(t *testing.T) { + gh, eng := newFixture(), &fakeEngine{} + + require.EqualError(t, run(t, Config{Advisory: "GHSA-new", Project: "acme/16", Force: true}, gh, eng), "a forced run triages again for the board only, so it needs comments off") + require.Empty(t, eng.calls) + require.Empty(t, gh.posted) +} + func TestRunWithoutCommentsGivesUpOnAnAnalysisThatKeepsStopping(t *testing.T) { gh, eng := newFixture(), &fakeEngine{} gh.Add(map[string]any{"GHSA": "GHSA-new", "State": "triage", "Priority": "Analysis pending", "Attempts": 3})