Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion cmd/tamis/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions internal/triage/triage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions internal/triage/triage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
Loading