Skip to content

Commit 278df12

Browse files
dannysteenmanwesmclaude
authored
Add configurable PR comment upsert and fix UTF-8 truncation (#411)
## Summary CI review comments are always created as new comments. This adds an opt-in `ci.upsert_comments` setting that finds the existing roborev marker comment and patches it in-place instead. - Add `ci.upsert_comments` config (global and per-repo, default false) - Add `FindExistingComment`, `UpsertPRComment`, and `CreatePRComment` in `internal/github` - Fall back to creating a new comment when PATCH returns 403/404 (token mismatch) - Target the newest marker comment to reduce fallback churn - Fix UTF-8 truncation: replace `utf8.ValidString` full-string scan with boundary-only `TrimPartialRune` - Propagate caller context through `postCIComment` for Ctrl+C cancellation ### Config ```toml # Global (~/.roborev/config.toml) [ci] upsert_comments = true # Per-repo (.roborev.toml) — overrides global [ci] upsert_comments = false ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c9f868 commit 278df12

File tree

11 files changed

+1038
-43
lines changed

11 files changed

+1038
-43
lines changed

cmd/roborev/ci.go

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import (
77
"fmt"
88
"log"
99
"os"
10-
"os/exec"
1110
"strconv"
1211
"strings"
1312

1413
"github.com/roborev-dev/roborev/internal/config"
1514
"github.com/roborev-dev/roborev/internal/git"
15+
ghpkg "github.com/roborev-dev/roborev/internal/github"
1616
"github.com/roborev-dev/roborev/internal/review"
1717
"github.com/spf13/cobra"
1818
)
@@ -264,8 +264,10 @@ func runCIReview(ctx context.Context, opts ciReviewOpts) error {
264264
prNumber = detected
265265
}
266266

267+
upsert := resolveCIUpsertComments(
268+
repoCfg, globalCfg)
267269
if err := postCIComment(
268-
ghRepo, prNumber, comment,
270+
ctx, ghRepo, prNumber, comment, upsert,
269271
); err != nil {
270272
return fmt.Errorf(
271273
"post PR comment: %w", err)
@@ -380,6 +382,21 @@ func resolveCISynthesisAgent(
380382
return ""
381383
}
382384

385+
// resolveCIUpsertComments determines whether to upsert PR comments.
386+
// Priority: repo config > global config > false.
387+
func resolveCIUpsertComments(
388+
repoCfg *config.RepoConfig,
389+
globalCfg *config.Config,
390+
) bool {
391+
if repoCfg != nil && repoCfg.CI.UpsertComments != nil {
392+
return *repoCfg.CI.UpsertComments
393+
}
394+
if globalCfg != nil {
395+
return globalCfg.CI.UpsertComments
396+
}
397+
return false
398+
}
399+
383400
func splitTrimmed(s string) []string {
384401
parts := strings.Split(s, ",")
385402
out := make([]string, 0, len(parts))
@@ -477,30 +494,18 @@ func extractHeadSHA(gitRef string) string {
477494
return gitRef
478495
}
479496

480-
// postCIComment posts a comment on a GitHub PR using gh CLI.
481-
// Truncates the body to stay within GitHub's comment limit.
497+
// postCIComment posts a roborev comment on a GitHub PR.
498+
// When upsert is true, it finds and patches an existing marker comment;
499+
// otherwise it always creates a new comment.
482500
func postCIComment(
501+
ctx context.Context,
483502
ghRepo string,
484503
prNumber int,
485504
body string,
505+
upsert bool,
486506
) error {
487-
if len(body) > review.MaxCommentLen {
488-
body = body[:review.MaxCommentLen] +
489-
"\n\n...(truncated — comment exceeded " +
490-
"size limit)"
507+
if upsert {
508+
return ghpkg.UpsertPRComment(ctx, ghRepo, prNumber, body, nil)
491509
}
492-
493-
ghCmd := exec.Command("gh", "pr", "comment",
494-
"--repo", ghRepo,
495-
strconv.Itoa(prNumber),
496-
"--body-file", "-")
497-
ghCmd.Stdin = strings.NewReader(body)
498-
ghCmd.Stderr = os.Stderr
499-
500-
if out, err := ghCmd.Output(); err != nil {
501-
return fmt.Errorf(
502-
"gh pr comment: %v (output: %s)",
503-
err, string(out))
504-
}
505-
return nil
510+
return ghpkg.CreatePRComment(ctx, ghRepo, prNumber, body, nil)
506511
}

cmd/roborev/ci_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"runtime"
88
"strings"
99
"testing"
10+
11+
"github.com/roborev-dev/roborev/internal/config"
1012
)
1113

1214
func TestCIReviewCmd_Help(t *testing.T) {
@@ -235,6 +237,66 @@ func TestResolveReviewTypes_EmptyFlag(t *testing.T) {
235237
}
236238
}
237239

240+
func boolPtr(v bool) *bool { return &v }
241+
242+
func TestResolveCIUpsertComments(t *testing.T) {
243+
tests := []struct {
244+
name string
245+
repo *config.RepoConfig
246+
global *config.Config
247+
want bool
248+
}{
249+
{
250+
name: "nil/nil defaults to false",
251+
repo: nil, global: nil, want: false,
252+
},
253+
{
254+
name: "global true",
255+
repo: nil,
256+
global: &config.Config{CI: config.CIConfig{UpsertComments: true}},
257+
want: true,
258+
},
259+
{
260+
name: "global false",
261+
repo: nil,
262+
global: &config.Config{CI: config.CIConfig{UpsertComments: false}},
263+
want: false,
264+
},
265+
{
266+
name: "repo true overrides global false",
267+
repo: &config.RepoConfig{
268+
CI: config.RepoCIConfig{UpsertComments: boolPtr(true)},
269+
},
270+
global: &config.Config{CI: config.CIConfig{UpsertComments: false}},
271+
want: true,
272+
},
273+
{
274+
name: "repo false overrides global true",
275+
repo: &config.RepoConfig{
276+
CI: config.RepoCIConfig{UpsertComments: boolPtr(false)},
277+
},
278+
global: &config.Config{CI: config.CIConfig{UpsertComments: true}},
279+
want: false,
280+
},
281+
{
282+
name: "repo nil falls through to global",
283+
repo: &config.RepoConfig{
284+
CI: config.RepoCIConfig{UpsertComments: nil},
285+
},
286+
global: &config.Config{CI: config.CIConfig{UpsertComments: true}},
287+
want: true,
288+
},
289+
}
290+
for _, tt := range tests {
291+
t.Run(tt.name, func(t *testing.T) {
292+
got := resolveCIUpsertComments(tt.repo, tt.global)
293+
if got != tt.want {
294+
t.Errorf("resolveCIUpsertComments() = %v, want %v", got, tt.want)
295+
}
296+
})
297+
}
298+
}
299+
238300
func TestSplitTrimmed(t *testing.T) {
239301
tests := []struct {
240302
in string

internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,11 @@ type CIConfig struct {
299299
// Valid values: critical, high, medium, low. Empty means no filter (include all).
300300
MinSeverity string `toml:"min_severity"`
301301

302+
// UpsertComments enables updating existing PR comments instead of
303+
// creating new ones. When true, roborev searches for its marker
304+
// comment and patches it. Default: false (create a new comment each run).
305+
UpsertComments bool `toml:"upsert_comments"`
306+
302307
// GitHub App authentication (optional — comments appear as bot instead of personal account)
303308
GitHubAppConfig
304309
}
@@ -515,6 +520,10 @@ type RepoCIConfig struct {
515520

516521
// MinSeverity overrides the minimum severity filter for CI synthesis.
517522
MinSeverity string `toml:"min_severity"`
523+
524+
// UpsertComments overrides the global ci.upsert_comments setting.
525+
// Use a pointer so we can distinguish "not set" from "explicitly false".
526+
UpsertComments *bool `toml:"upsert_comments"`
518527
}
519528

520529
// RepoConfig holds per-repo overrides

internal/daemon/ci_poller.go

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/roborev-dev/roborev/internal/agent"
2121
"github.com/roborev-dev/roborev/internal/config"
2222
gitpkg "github.com/roborev-dev/roborev/internal/git"
23+
ghpkg "github.com/roborev-dev/roborev/internal/github"
2324
reviewpkg "github.com/roborev-dev/roborev/internal/review"
2425
"github.com/roborev-dev/roborev/internal/storage"
2526
)
@@ -1648,28 +1649,30 @@ func formatPRComment(review *storage.Review, verdict string) string {
16481649
return b.String()
16491650
}
16501651

1651-
// postPRComment posts a comment on a GitHub PR using the gh CLI.
1652-
// Truncates the body to stay within GitHub's ~65536 character limit.
1652+
// postPRComment posts a roborev comment on a GitHub PR.
1653+
// When upsert_comments is enabled (per-repo > global > false),
1654+
// it finds and patches an existing marker comment; otherwise it
1655+
// always creates a new comment.
16531656
func (p *CIPoller) postPRComment(ghRepo string, prNumber int, body string) error {
1654-
if len(body) > reviewpkg.MaxCommentLen {
1655-
body = body[:reviewpkg.MaxCommentLen] +
1656-
"\n\n...(truncated — comment exceeded " +
1657-
"size limit)"
1658-
}
1659-
16601657
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
16611658
defer cancel()
1662-
cmd := exec.CommandContext(ctx, "gh", "pr", "comment",
1663-
"--repo", ghRepo,
1664-
fmt.Sprintf("%d", prNumber),
1665-
"--body-file", "-",
1666-
)
1667-
cmd.Stdin = strings.NewReader(body)
1668-
if env := p.ghEnvForRepo(ghRepo); env != nil {
1669-
cmd.Env = env
1659+
env := p.ghEnvForRepo(ghRepo)
1660+
if p.resolveUpsertComments(ghRepo) {
1661+
return ghpkg.UpsertPRComment(ctx, ghRepo, prNumber, body, env)
16701662
}
1671-
if out, err := cmd.CombinedOutput(); err != nil {
1672-
return fmt.Errorf("gh pr comment: %s: %s", err, string(out))
1663+
return ghpkg.CreatePRComment(ctx, ghRepo, prNumber, body, env)
1664+
}
1665+
1666+
// resolveUpsertComments determines whether to upsert PR comments
1667+
// for the given repo. Per-repo config takes priority over global.
1668+
func (p *CIPoller) resolveUpsertComments(ghRepo string) bool {
1669+
repo, err := p.findLocalRepo(ghRepo)
1670+
if err == nil && repo != nil {
1671+
repoCfg, err := loadCIRepoConfig(repo.RootPath)
1672+
if err == nil && repoCfg != nil &&
1673+
repoCfg.CI.UpsertComments != nil {
1674+
return *repoCfg.CI.UpsertComments
1675+
}
16731676
}
1674-
return nil
1677+
return p.cfgGetter.Config().CI.UpsertComments
16751678
}

internal/daemon/ci_poller_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3087,3 +3087,54 @@ func TestCIPollerProcessPR_AgentFailureSetsErrorStatus(t *testing.T) {
30873087
)
30883088
}
30893089
}
3090+
3091+
func TestResolveUpsertComments_DefaultFalse(t *testing.T) {
3092+
h := newCIPollerHarness(t, "https://github.com/acme/api.git")
3093+
if h.Poller.resolveUpsertComments("acme/api") {
3094+
t.Fatal("expected false by default")
3095+
}
3096+
}
3097+
3098+
func TestResolveUpsertComments_GlobalTrue(t *testing.T) {
3099+
h := newCIPollerHarness(t, "https://github.com/acme/api.git")
3100+
h.Cfg.CI.UpsertComments = true
3101+
if !h.Poller.resolveUpsertComments("acme/api") {
3102+
t.Fatal("expected true from global config")
3103+
}
3104+
}
3105+
3106+
func TestResolveUpsertComments_RepoOverridesGlobal(t *testing.T) {
3107+
h := newCIPollerHarness(t, "https://github.com/acme/api.git")
3108+
h.Cfg.CI.UpsertComments = true
3109+
3110+
// Write a .roborev.toml in the repo that disables upsert.
3111+
tomlPath := filepath.Join(h.RepoPath, ".roborev.toml")
3112+
err := os.WriteFile(tomlPath, []byte(
3113+
"[ci]\nupsert_comments = false\n",
3114+
), 0o644)
3115+
if err != nil {
3116+
t.Fatal(err)
3117+
}
3118+
3119+
if h.Poller.resolveUpsertComments("acme/api") {
3120+
t.Fatal("expected repo config (false) to override global (true)")
3121+
}
3122+
}
3123+
3124+
func TestResolveUpsertComments_RepoEnablesOverGlobal(t *testing.T) {
3125+
h := newCIPollerHarness(t, "https://github.com/acme/api.git")
3126+
// Global default is false.
3127+
3128+
// Write a .roborev.toml in the repo that enables upsert.
3129+
tomlPath := filepath.Join(h.RepoPath, ".roborev.toml")
3130+
err := os.WriteFile(tomlPath, []byte(
3131+
"[ci]\nupsert_comments = true\n",
3132+
), 0o644)
3133+
if err != nil {
3134+
t.Fatal(err)
3135+
}
3136+
3137+
if !h.Poller.resolveUpsertComments("acme/api") {
3138+
t.Fatal("expected repo config (true) to override global (false)")
3139+
}
3140+
}

0 commit comments

Comments
 (0)