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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,9 @@ If you're an autonomous agent running a PR-review loop, here's everything you ne
because a thread left open keeps its finding actionable; `--keep-open` overrides.
- **Dismiss what has no thread:** a finding with no `thread_id` cannot be resolved or declined, and
blocks every future round until it is accounted for: `crq dismiss <repo> <pr> <finding-id>
--reason "…"`. It covers the current head only. A `source: "review_comment"`
--reason "…"`. It covers the current head only, and posts one PR comment naming the
findings and the reason (pass every ID in one call; a replay posts nothing, and a failed post
is reported in `warning` without undoing the dismissal). A `source: "review_comment"`
finding is refused: it lost its thread ID to crq's REST fallback and still has
an open thread, so it needs `resolve`/`decline` once crq can read threads again. For a skipped review, narrowing the PR fixes the
cause; dismissing only records that you chose to proceed.
Expand Down
11 changes: 8 additions & 3 deletions cmd/crq/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,11 @@ Finding IDs come from .findings[].id. They are content-derived, not GitHub node
IDs, so the repo and PR are required. A dismissal covers the current head only:
push, and the next reviewer has to report it again.

Each call posts one PR comment naming the findings it dismissed and the reason,
so the PR shows they were judged. Pass every ID in one call to get one comment;
a replay posts nothing. If the comment cannot be posted, the output carries a
warning and the dismissal still stands.

Use it for a finding you have judged and set aside. Fix what is real instead — and
for a review crq was told was SKIPPED, narrowing the PR fixes the cause, while
dismissing only records that you decided to live with it at this head.
Expand Down Expand Up @@ -2876,9 +2881,9 @@ func (a prActor) DeclineThreads(ctx context.Context, threadIDs []string, reason
return err
}

func (a prActor) DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) error {
_, err := a.svc.Dismiss(ctx, repo, pr, ids, reason)
return err
func (a prActor) DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) (string, error) {
result, err := a.svc.Dismiss(ctx, repo, pr, ids, reason)
return result.Warning, err
}

// repoDiscoverer lists the repositories in CRQ_SCOPE for the dashboard's
Expand Down
75 changes: 75 additions & 0 deletions internal/crq/dismiss.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"

"github.com/kristofferR/codereview-queue/internal/dialect"
Expand All @@ -21,6 +22,10 @@ type DismissResult struct {
Dismissed []string `json:"dismissed"`
Already []string `json:"already_dismissed,omitempty"`
Reason string `json:"reason"`
// CommentURL is the PR comment naming what this call dismissed, and Warning
// says why it is missing when posting failed. The dismissal stands either way.
CommentURL string `json:"comment_url,omitempty"`
Warning string `json:"warning,omitempty"`
}

// Dismiss records that an agent has accounted for findings GitHub gives it no
Expand Down Expand Up @@ -88,7 +93,9 @@ func (s *Service) Dismiss(ctx context.Context, repo string, pr int, ids []string
// agree.
alreadyDone := map[string]bool{}
var seenSeq int64
var cfg Config
if st, _, err := s.store.Load(ctx); err == nil {
cfg = s.cfgFor(st, repo)
if round := st.Round(repo, pr); round != nil {
seenSeq = round.Seq
if round.Head == feedback.Head {
Expand Down Expand Up @@ -181,9 +188,77 @@ func (s *Service) Dismiss(ctx context.Context, repo string, pr int, ids []string
}
s.log.Printf("%s#%d %s %d finding(s) at %s: %s", repo, pr, verb, len(out.Dismissed), out.Head, reason)
}
if !s.cfg.DryRun {
out = s.postDismissNotice(ctx, out, current, cfg)
}
return out, nil
}

// dismissComment renders one notice for every finding a call dismissed. It is
// a human's comment to crq: the author is never a feedback bot, and the text is
// neutralized so quoted finding data or reasons cannot trigger or ping a reviewer.
func dismissComment(head string, findings []dialect.Finding, reason string, cfg Config) string {
var b strings.Builder
noun := "finding"
if len(findings) != 1 {
noun = "findings"
}
fmt.Fprintf(&b, "<!-- crq:dismiss -->\nDismissed %d %s at `%s`:\n\n", len(findings), noun, shortSHA(head))
for _, finding := range findings {
line := dialect.NormalizeBotName(finding.Bot) + ": " + noticeTitle(finding.Title)
var refs []string
if finding.Path != "" {
where := finding.Path
if finding.Line > 0 {
where += ":" + strconv.Itoa(finding.Line)
}
refs = append(refs, dismissPath(where))
}
if finding.URL != "" {
refs = append(refs, "[source]("+finding.URL+")")
}
if len(refs) > 0 {
line += " (" + strings.Join(refs, ", ") + ")"
}
b.WriteString("- " + line + "\n")
}
b.WriteString("\n**Reason:** " + reason)
return neutralizeReviewCommands(b.String(), cfg)
}

// dismissPath keeps a path on one line and inside a Markdown code span, even
// when the filename contains backticks.
func dismissPath(path string) string {
path = strings.Join(strings.Fields(path), " ")
longest, run := 0, 0
for _, r := range path {
if r == '`' {
run++
longest = max(longest, run)
} else {
run = 0
}
}
delimiter := strings.Repeat("`", longest+1)
if longest > 0 {
path = " " + path + " "
}
return delimiter + path + delimiter
}

// noticeTitle fits a finding title on one list line.
func noticeTitle(title string) string {
title = strings.Join(strings.Fields(title), " ")
if title == "" {
return "(untitled)"
}
const limit = 160
if runes := []rune(title); len(runes) > limit {
return string(runes[:limit-1]) + "…"
}
return title
}

// dismissibleSources are the finding kinds that intrinsically have no review
// thread. Everything else either has one, or only LOOKS threadless: the REST
// fallback Feedback uses when the GraphQL thread query fails emits inline
Expand Down
139 changes: 139 additions & 0 deletions internal/crq/dismiss_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package crq

import (
"errors"
"strings"
"testing"
"time"

"github.com/kristofferR/codereview-queue/internal/dialect"
"github.com/kristofferR/codereview-queue/internal/engine"
ghapi "github.com/kristofferR/codereview-queue/internal/gh"
)

// Only a finding that intrinsically cannot have a thread may be dismissed.
Expand Down Expand Up @@ -36,3 +41,137 @@ func TestOnlyIntrinsicallyThreadlessFindingsAreDismissible(t *testing.T) {
t.Error("a finding with a thread must never be dismissible")
}
}

// dismissFixture reports two threadless findings at one head and returns their IDs.
func dismissFixture(t *testing.T, pr int) (*replayFixture, string, []string) {
t.Helper()
f := newReplayFixture(t, time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC))
repo, head := "owner/repo", "aaaaaaaa1"
f.openPull(repo, pr, head)
f.setCommitDate(head, f.clk.now().Add(-time.Minute))
f.setLocalWork(false, "")
f.next(repo, pr)
f.clk.advance(time.Minute)
f.corpusReview(t, repo, pr, 900, head, "coderabbit/findings-prompt-block.md")
report := f.next(repo, pr)
f.wantAction(report, engine.ActionFix)
var ids []string
for _, finding := range report.Findings {
if finding.ThreadID == "" {
ids = append(ids, finding.ID)
}
}
if len(ids) < 2 {
t.Fatalf("need two threadless findings, got %d", len(ids))
}
return f, repo, ids
}

func (f *replayFixture) dismissNotices(repo string, pr int) []string {
f.gh.mu.Lock()
defer f.gh.mu.Unlock()
prefix := QueueKey(repo, pr) + ":<!-- crq:dismiss -->"
var out []string
for _, p := range f.gh.posted {
if strings.HasPrefix(p, prefix) {
out = append(out, p)
}
}
return out
}

// A dismissal has no thread to reply on, so it answers on the PR instead: one
// comment per call, never repeated when an agent replays the call.
func TestDismissPostsOneNoticePerCall(t *testing.T) {
pr := 700
f, repo, ids := dismissFixture(t, pr)

res, err := f.svc.Dismiss(f.ctx, repo, pr, ids, "covered by the parser tests")
if err != nil {
t.Fatal(err)
}
if res.Warning != "" {
t.Fatalf("unexpected warning: %s", res.Warning)
}
notices := f.dismissNotices(repo, pr)
if len(notices) != 1 {
t.Fatalf("posted %d notices, want one for the whole call", len(notices))
}
for _, want := range []string{"Dismissed 2 findings at `aaaaaaaa1`", "`src/app.ts:12`", "`README.md:7`", "**Reason:** covered by the parser tests"} {
if !strings.Contains(notices[0], want) {
t.Errorf("notice lacks %q:\n%s", want, notices[0])
}
}

if _, err := f.svc.Dismiss(f.ctx, repo, pr, ids, "covered by the parser tests"); err != nil {
t.Fatal(err)
}
if got := len(f.dismissNotices(repo, pr)); got != 1 {
t.Errorf("a replayed dismissal posted again: %d notices", got)
}

// The notice on the PR is crq's own words, not a reviewer's: reading it back
// must not reopen the round it just cleared.
f.gh.mu.Lock()
notice := ghapi.IssueComment{ID: 5000, Body: strings.SplitN(notices[0], ":", 2)[1], CreatedAt: f.clk.now(), UpdatedAt: f.clk.now()}
notice.User.Login = "kristofferR"
f.gh.comments[fakeKey(repo, pr)] = append(f.gh.comments[fakeKey(repo, pr)], notice)
f.gh.mu.Unlock()
f.clk.advance(time.Minute)
if after := f.next(repo, pr); after.Action == string(engine.ActionFix) {
t.Fatalf("the dismissal notice was read back as a finding: %+v", after.Findings)
}
}

// The recorded dismissal is the decision; the comment only reports it.
func TestDismissKeepsTheRecordWhenTheNoticeFails(t *testing.T) {
pr := 701
f, repo, ids := dismissFixture(t, pr)
f.gh.mu.Lock()
f.gh.postErrs = map[string]error{fakeKey(repo, pr): errors.New("boom")}
f.gh.mu.Unlock()

res, err := f.svc.Dismiss(f.ctx, repo, pr, ids, "covered by the parser tests")
if err != nil {
t.Fatalf("a failed notice must not fail the dismissal: %v", err)
}
if len(res.Dismissed) != len(ids) || !strings.Contains(res.Warning, "boom") {
t.Fatalf("result = %+v, want every id dismissed and the post failure reported", res)
}
if r := f.round(repo, pr); r == nil || !r.IsDismissed(ids[0]) {
t.Fatalf("the dismissal was not recorded: %+v", r)
}
}

// Quoted finding data and reasons must not ping or trigger a reviewer, which would
// answer on the PR and could start a review nobody queued.
func TestDismissCommentNeutralizesReviewCommands(t *testing.T) {
cfg := replayConfig()
body := dismissComment("0123456789abcdef", []dialect.Finding{{
Bot: "coderabbitai[bot]",
Title: "Ask @coderabbitai\n to recheck",
Path: "src/`\n" + cfg.ReviewCommand + "\n``.ts",
URL: "https://github.com/owner/repo/pull/1#pullrequestreview-9",
}}, "see "+cfg.ReviewCommand, cfg)

if strings.Contains(body, "@coderabbitai") || strings.Contains(body, cfg.ReviewCommand) {
t.Errorf("notice can still mention or trigger a reviewer:\n%s", body)
}
for _, want := range []string{
"Dismissed 1 finding at `012345678`",
"- coderabbitai: Ask @\u200bcoderabbitai to recheck (``` src/` @\u200b\u200bcoderabbitai review ``.ts ```, [source](https://github.com/owner/repo/pull/1#pullrequestreview-9))",
} {
if !strings.Contains(body, want) {
t.Errorf("notice lacks %q:\n%s", want, body)
}
}
}

func TestDismissCommentNeutralizesConfiguredCommandsInPaths(t *testing.T) {
cfg := replayConfig()
cfg.ReviewCommand = "/review now"
body := dismissComment("aaaaaaaa1", []dialect.Finding{{Path: "src/`\n/review now\n`.go"}}, "verified", cfg)
if strings.Contains(body, cfg.ReviewCommand) {
t.Fatalf("path can trigger a configured reviewer:\n%s", body)
}
}
3 changes: 2 additions & 1 deletion internal/crq/dispatch/fix-prompt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ Then:
reviewed commit, so `crq dismiss` rejects it after a push changes the current
head. Once you have judged it:
crq dismiss "$CRQ_DISPATCH_REPO" "$CRQ_DISPATCH_PR" <finding-id>... --reason "why"
A real reason is required. Do not dismiss a threadless finding carried from
A real reason is required, and it is posted on the PR. Pass every ID in one
call so they share one comment. Do not dismiss a threadless finding carried from
an older commit: the push already superseded it.
6. Ask crq whether the head may move, then push:
crq next "$CRQ_DISPATCH_REPO" "$CRQ_DISPATCH_PR"
Expand Down
2 changes: 1 addition & 1 deletion internal/crq/next_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ func TestDismissEndsTheUnresolvableFindingDeadlock(t *testing.T) {
if _, err := f.svc.Dismiss(f.ctx, repo, pr, []string{id}, "already handled in an earlier commit"); err != nil {
t.Fatal(err)
}
// Dismissing is a record, not a review: it must post nothing of its own,
// Dismissing is a record, not a review: it must not post a review command,
// even though it enqueues so the decision has a round to live on.
if got := f.reviewsPosted(repo, pr); got != posted {
t.Fatalf("dismissing posted a review: %d -> %d", posted, got)
Expand Down
31 changes: 31 additions & 0 deletions internal/crq/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,37 @@ func observedAccountBlockChanges(q AccountQuota, blk *engine.AccountBlock) bool
return true
}

// postDismissNotice leaves the dismissal on the PR, the way decline leaves its
// reason on the thread. Without it a reader sees a bot's findings with no answer
// and cannot tell a judged finding from a missed one.
//
// Only the IDs this call newly recorded are named, so a replayed dismissal posts
// nothing again. The state write already committed: a failed post is reported,
// never rolled back.
func (s *Service) postDismissNotice(ctx context.Context, out DismissResult, current map[string]dialect.Finding, cfg Config) DismissResult {
findings := make([]dialect.Finding, 0, len(out.Dismissed))
for _, id := range out.Dismissed {
// An ID absent here was dismissed before, at this head, and archived by a
// confirmation pass; its notice was posted then.
if finding, ok := current[id]; ok {
findings = append(findings, finding)
}
}
if len(findings) == 0 {
return out
}
comment, err := s.gh.PostIssueComment(ctx, out.Repo, out.PR, dismissComment(out.Head, findings, out.Reason, cfg))
if err != nil {
out.Warning = "dismissal recorded, but its PR comment could not be posted: " + err.Error()
if s.log != nil {
s.log.Printf("warning: %s#%d dismissal recorded but its PR comment could not be posted: %v", out.Repo, out.PR, err)
}
return out
}
out.CommentURL = comment.URL
return out
}

// recordDismissal is the effects executor for `crq dismiss`: the CAS write that
// records which findings a round has accounted for. Dismiss decides WHETHER a
// dismissal is legitimate; this performs it, so the write surface stays in one
Expand Down
5 changes: 3 additions & 2 deletions internal/serve/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type Actor interface {
// finding GitHub gives no way to close.
ResolveThreads(ctx context.Context, threadIDs []string) error
DeclineThreads(ctx context.Context, threadIDs []string, reason string, resolve bool) error
DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) error
DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) (warning string, err error)
}

type actionRequest struct {
Expand Down Expand Up @@ -310,7 +310,8 @@ func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) {
return
}
err = s.needPR(req, func() error {
return s.actor.DismissFindings(ctx, req.Repo, req.PR, req.FindingIDs, req.Reason)
warning, err = s.actor.DismissFindings(ctx, req.Repo, req.PR, req.FindingIDs, req.Reason)
return err
})
default:
http.NotFound(w, r)
Expand Down
Loading
Loading