Skip to content
Open
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
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ inputs:
issues-token:
description: GitHub App installation token for `issues-repository` only, with the "Issues" and organisation "Projects" permissions in read and write. tamis refuses it when it also reaches `repository`, so a bug cannot publish a report there.
required: false
close-duplicates:
description: Close the advisory of an issue a maintainer closed as a duplicate, credit its reporter on the original report and tell them on the closed advisory. Needs `issues-repository`.
default: "false"
comment:
description: Post the triage as a comment on the advisory. Off by default, every triage then only reaches `project`, which must be set.
default: "false"
Expand Down Expand Up @@ -143,6 +146,7 @@ runs:
PROJECT: ${{ inputs.project }}
ISSUES_REPOSITORY: ${{ inputs.issues-repository }}
ISSUES_TOKEN: ${{ inputs.issues-token }}
CLOSE_DUPLICATES: ${{ inputs.close-duplicates }}
COMMENT: ${{ inputs.comment }}
PACKS: ${{ inputs.packs }}
- name: Store private reports
Expand Down
9 changes: 9 additions & 0 deletions cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@ func run(ctx context.Context, log *slog.Logger) error {
}
cfg.Issues = github.New(issues)
}
if v := os.Getenv("CLOSE_DUPLICATES"); v != "" {
if cfg.CloseDuplicates, err = strconv.ParseBool(v); err != nil {
return fmt.Errorf("CLOSE_DUPLICATES: %w", err)
}
}
// Only an issue carries the decision of a maintainer, a draft item has no close reason.
if cfg.CloseDuplicates && cfg.IssuesRepository == "" {
return errors.New("CLOSE_DUPLICATES needs ISSUES_REPOSITORY")
}
eng, agent, model, err := newEngine(os.Getenv("ENGINE"), os.Getenv("MODEL"), os.Getenv("LLM_API_KEY"))
if err != nil {
return err
Expand Down
4 changes: 3 additions & 1 deletion cmd/tamis/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ func TestRunValidatesConfiguration(t *testing.T) {
"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 close": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "CLOSE_DUPLICATES": "maybe"}, "CLOSE_DUPLICATES"},
"close without issues": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "CLOSE_DUPLICATES": "true"}, "CLOSE_DUPLICATES needs ISSUES_REPOSITORY"},
"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", "FORCE"} {
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", "CLOSE_DUPLICATES", "COMMENT", "PACKS", "FORCE"} {
t.Setenv(k, tc.env[k])
}

Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
| `project` | | Private organisation project kept in sync with the advisories, as `org/number`. See [the project board](setup.md#5-project-board). |
| `issues-repository` | | Private repository the board items are created in as issues, so duplicates become sub-issues of their original report. Drafts when unset. See [the project board](setup.md#5-project-board). |
| `issues-token` | | Installation token for `issues-repository` only, with the "Issues" and organisation "Projects" permissions in read and write. Required with `issues-repository`. |
| `close-duplicates` | `false` | Closes the advisory of an issue a maintainer closed as a duplicate. Needs `issues-repository`. See [the project board](setup.md#5-project-board). |
| `runner-image` | tamis agent image, pinned by digest | Image the agent runs in. It must contain the agent CLI, git, curl and `harness-proxy`. The action skips the attestation check for a custom image. |

The action refuses to run from a public workflow repository or from a `uses:` that is not a full commit SHA. Update that SHA through a reviewed pull request.

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.
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`, `CLOSE_DUPLICATES`, `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.

Expand Down
4 changes: 3 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,6 @@ tamis writes the issues and adds them to the project with `issues-token`, a seco

Then pass `issues-token: ${{ steps.issues.outputs.token }}`. The App needs the "Issues" permission in read and write and an installation on the issues repository.

With `comment` left at `false`, tamis posts nothing on the advisories. The item of an advisory says it was triaged, `Attempts` counts its failed attempts in place of the failure comments, and deleting an item triages its advisory again on the next run.
With `close-duplicates: true`, closing an issue as a duplicate decides its report. At the start of the next run, tamis credits the reporter as `reporter` on the `Original report` of the issue when that report is still in `triage` or `draft`, comments on the advisory to name that report, and closes it. A note on the issue then names the original and the login credited on it. The original is always the one of `Original report`, whichever issue the duplicate was closed against, and an advisory without one stays as it is. The comment names the original even under embargo, and whoever can close an issue of that repository can then close an advisory.

With `comment` left at `false`, tamis posts nothing on the advisories but the comment of `close-duplicates`. The item of an advisory says it was triaged, `Attempts` counts its failed attempts in place of the failure comments, and deleting an item triages its advisory again on the next run.
94 changes: 93 additions & 1 deletion internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ type item struct {
// issue, zero without one.
issue int
issueID, parent int64
// closedAs is the state reason of a closed issue, such as "duplicate", empty while it is open.
closedAs string
// triage is nil for an item without one, whose priority is a status Sync leaves alone.
triage *Triage
}
Expand Down Expand Up @@ -330,7 +332,16 @@ func Open(ctx context.Context, gh GitHub, ig Issues, project, issues, advisories
return nil, err
}
b.unlisted = map[string]github.Issue{}
byIssue := map[int]*item{}
for _, it := range b.items {
if it.issue != 0 {
byIssue[it.issue] = it
}
}
for _, i := range all {
if it := byIssue[i.Number]; it != nil && i.State == "closed" {
it.closedAs = i.StateReason
}
if ghsa := strings.ToLower(markedAdvisory(i.Body)); ghsa != "" && b.items[ghsa] == nil {
b.unlisted[ghsa] = i
}
Expand Down Expand Up @@ -641,6 +652,86 @@ func (b *Board) option(field, value string) github.FieldValue {
return v
}

// Advisories writes the advisories, with the token of their repository.
type Advisories interface {
GetAdvisory(ctx context.Context, repo, ghsa string) (github.Advisory, error)
UpdateAdvisory(ctx context.Context, repo, ghsa string, fields map[string]any) error
ListComments(ctx context.Context, repo, ghsa string) ([]github.Comment, error)
PostComment(ctx context.Context, repo, ghsa, body string) error
}

// CloseDuplicates closes the advisory of each issue a maintainer closed as a duplicate while that advisory waits
// for a decision, and tells its reporter on it. The reporter is credited on the original report of the group when
// that one waits too. An advisory the board names no original for stays as it is. Each advisory it closes is
// marked closed in advisories, so the rest of the run sees it.
func (b *Board) CloseDuplicates(ctx context.Context, gh Advisories, repo string, advisories []github.Advisory) error {
var errs []error
for i, a := range advisories {
it := b.items[strings.ToLower(a.GHSAID)]
if it == nil || it.closedAs != "duplicate" || it.duplicateOf == "" || a.State != "triage" && a.State != "draft" {
continue
}
// A Duplicate of chain edited by hand into a loop has no oldest report, the one it stops at names another.
original := b.oldest(it.duplicateOf)
if o := b.items[strings.ToLower(original)]; o != nil && o.duplicateOf != "" {
continue
}
credited, err := closeDuplicate(ctx, gh, repo, a, original)
if err != nil {
errs = append(errs, fmt.Errorf("closing %s as a duplicate of %s: %w", a.GHSAID, original, err))
continue
}
advisories[i].State = "closed"
// The note comes after the close, so a close that fails again next run never posts it twice.
note := fmt.Sprintf("Closed %s as a duplicate of %s", a.GHSAID, original)
if o := b.items[strings.ToLower(original)]; o != nil && o.issue != 0 {
note += fmt.Sprintf(" (#%d)", o.issue)
}
// A login in backticks notifies nobody.
if credited != "" {
note += fmt.Sprintf(", where `%s` is credited as a reporter", credited)
}
if err := b.ig.CommentIssue(ctx, b.issues, it.issue, note+"."); err != nil {
errs = append(errs, fmt.Errorf("noting the close of %s on its issue: %w", a.GHSAID, err))
}
}

return errors.Join(errs...)
}

// closeDuplicate comments before closing, since the next run leaves a closed advisory alone. A close that failed
// after its comment finds that comment on the next run instead of posting it again. It returns the login
// credited on the original, empty when the original credits nobody for it.
func closeDuplicate(ctx context.Context, gh Advisories, repo string, a github.Advisory, original string) (string, error) {
o, err := gh.GetAdvisory(ctx, repo, original)
if err != nil {
return "", err
}
comment := fmt.Sprintf("This report is closed as a duplicate of %s, reported earlier.", o.GHSAID)
credited := ""
if login := a.Author.Login; login != "" && !strings.EqualFold(login, o.Author.Login) && (o.State == "triage" || o.State == "draft") {
if !slices.ContainsFunc(o.Credits, func(c github.Credit) bool { return strings.EqualFold(c.Login, login) }) {
credits := append(o.Credits, github.Credit{Login: login, Type: "reporter"})
if err := gh.UpdateAdvisory(ctx, repo, o.GHSAID, map[string]any{"credits": credits}); err != nil {
return "", err
}
}
comment += " You are credited on it as a reporter."
credited = login
}
comments, err := gh.ListComments(ctx, repo, a.GHSAID)
if err != nil {
return "", err
}
if !slices.ContainsFunc(comments, func(c github.Comment) bool { return c.Body == comment }) {
if err := gh.PostComment(ctx, repo, a.GHSAID, comment); err != nil {
return "", err
}
}

return credited, gh.UpdateAdvisory(ctx, repo, a.GHSAID, map[string]any{"state": "closed"})
}

// Sync mirrors the state of every advisory on its item, counts the duplicates of each report, gives every
// report of a duplicate group the identifier of its oldest one and ranks every triage again, so a new priority
// rule reaches the board without a model run. It only writes the items that changed.
Expand All @@ -666,7 +757,8 @@ func (b *Board) Sync(ctx context.Context, advisories []github.Advisory) error {
for ghsa, it := range b.items {
var values []github.FieldValue
if state, ok := states[ghsa]; ok && state != it.state {
if it.issue != 0 {
// An issue a maintainer closed as a duplicate keeps that reason once its advisory closes.
if it.issue != 0 && it.closedAs != "duplicate" {
if err := b.ig.UpdateIssue(ctx, b.issues, it.issue, issueState(state)); err != nil {
errs = append(errs, err)
continue
Expand Down
Loading
Loading