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
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 10 additions & 9 deletions cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
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 @@ -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"},
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
8 changes: 7 additions & 1 deletion internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)]++
}
}
}

Expand Down
21 changes: 21 additions & 0 deletions internal/board/board_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading