Skip to content

Commit 86cc504

Browse files
feat: add the [approval_retention] config section
Adds the config surface the retention rules will hang off. No behaviour change: nothing reads these flags yet. The individual flags are *bool rather than bool so that unset can be told apart from an explicit false. That is what lets the umbrella work in both directions: on with nothing set turns every following flag on, and on with one flag set to false turns everything except that one on. string_literals, renames and fetch_orphaned_approval are opt-in and never follow the umbrella. The first two can alter behaviour without changing the shape of the code an approver reviewed; the third reaches the network. An end-to-end test pins the inertness claim: a config with no section and one spelling the section out with everything off produce the same bytes.
1 parent 4f1c66e commit 86cc504

5 files changed

Lines changed: 527 additions & 15 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ disable_review_status_comments = false
261261
# `admin_bypass` allows repository administrators to bypass codeowner requirements
262262
[admin_bypass]
263263
# see "Admin Bypass" below for more details
264+
265+
# `approval_retention` allows you to specify which kinds of changes may keep an existing approval
266+
[approval_retention]
267+
# see "Approval Retention" below for more details
264268
```
265269

266270
When a PR has any of the `high_priority_labels`, the comment will look like this:
@@ -330,6 +334,38 @@ Codeowners Plus automatically detects and validates the bypass approval, immedia
330334

331335
The bypass text is case-insensitive, so "codeowners bypass", "Codeowners Bypass", or "CODEOWNERS BYPASS" all work.
332336

337+
#### Approval Retention
338+
339+
The `approval_retention` section lists the kinds of changes which may keep an existing approval instead of dismissing it. Everything in it is opt-in: `enabled` turns the section on, and each kind of change has to be named as well. Upgrading never changes how your approvals behave.
340+
341+
`codeowners.toml`:
342+
```toml
343+
[approval_retention]
344+
# `enabled` (default false) turns the section on. On its own it retains nothing.
345+
enabled = true
346+
# Each flag below defaults to false and has to be asked for by name.
347+
# `whitespace` retains approvals across whitespace-only changes
348+
whitespace = true
349+
# `comments` retains approvals across comment-only changes
350+
comments = true
351+
# `formatting` retains approvals across formatting-only changes
352+
formatting = true
353+
# `string_literals` retains approvals across string literal changes
354+
string_literals = false
355+
# `renames` retains approvals across renames
356+
renames = false
357+
# `fetch_orphaned_approval` looks for approvals which are no longer
358+
# attached to the current commit
359+
fetch_orphaned_approval = false
360+
```
361+
362+
- Nothing set: every flag is off
363+
- `enabled = true` and nothing else: every flag is still off
364+
- `enabled = false` with flags set to `true`: every flag is off, so the section is a single kill switch
365+
- `enabled = true` with `whitespace = true`: whitespace only
366+
367+
What counts as a change not worth re-reviewing is a judgement about a particular codebase, not something to inherit from a default. Two flags deserve extra thought before you name them. A change to a string literal or a rename can alter behavior without changing the shape of the code the approver reviewed, so retaining an approval across one is a stronger claim than the other categories. And `fetch_orphaned_approval` is the only flag in the section which reaches outside the checkout, so it adds network calls to a run.
368+
333369
#### Require Both Branch Reviewers (Ownership Handoffs)
334370

335371
The `require_both_branch_reviewers` feature enables self-service ownership transfers by requiring approval from codeowners defined in **BOTH** the base branch and the PR branch. This creates an AND relationship between ownership rules from both branches.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package app
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"slices"
9+
"strings"
10+
"testing"
11+
12+
"github.com/google/go-github/v89/github"
13+
"github.com/multimediallc/codeowners-plus/internal/git"
14+
gh "github.com/multimediallc/codeowners-plus/internal/github"
15+
"github.com/multimediallc/codeowners-plus/pkg/codeowners"
16+
)
17+
18+
// The shared mock approves everything without reading the diff, which is the
19+
// decision under test, so the real staleness check is spliced back in.
20+
type realCheckApprovalsClient struct {
21+
*mockGitHubClient
22+
real gh.Client
23+
dismissed []*gh.CurrentApproval
24+
}
25+
26+
func (c *realCheckApprovalsClient) CheckApprovals(
27+
fileReviewerMap map[string][]string,
28+
approvals []*gh.CurrentApproval,
29+
originalDiff git.Diff,
30+
) ([]codeowners.Slug, []*gh.CurrentApproval) {
31+
return c.real.CheckApprovals(fileReviewerMap, approvals, originalDiff)
32+
}
33+
34+
func (c *realCheckApprovalsClient) DismissStaleReviews(approvals []*gh.CurrentApproval) error {
35+
c.dismissed = append(c.dismissed, approvals...)
36+
return c.mockGitHubClient.DismissStaleReviews(approvals)
37+
}
38+
39+
func runGit(t *testing.T, dir string, args ...string) string {
40+
t.Helper()
41+
cmd := exec.Command("git", args...)
42+
cmd.Dir = dir
43+
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
44+
out, err := cmd.CombinedOutput()
45+
if err != nil {
46+
t.Fatalf("git %s (in %s): %v\n%s", strings.Join(args, " "), dir, err, out)
47+
}
48+
return strings.TrimSpace(string(out))
49+
}
50+
51+
func initRepo(t *testing.T, dir string) {
52+
t.Helper()
53+
runGit(t, dir, "init", "-q", "-b", "main")
54+
runGit(t, dir, "config", "user.email", "test@example.invalid")
55+
runGit(t, dir, "config", "user.name", "Test User")
56+
runGit(t, dir, "config", "commit.gpgsign", "false")
57+
}
58+
59+
func writeRepoFile(t *testing.T, dir, name, content string) {
60+
t.Helper()
61+
path := filepath.Join(dir, name)
62+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
63+
t.Fatalf("mkdir for %s: %v", name, err)
64+
}
65+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
66+
t.Fatalf("write %s: %v", name, err)
67+
}
68+
}
69+
70+
func commitAll(t *testing.T, dir, message string) string {
71+
t.Helper()
72+
runGit(t, dir, "add", "-A")
73+
runGit(t, dir, "commit", "-q", "-m", message)
74+
return runGit(t, dir, "rev-parse", "HEAD")
75+
}
76+
77+
func runApp(t *testing.T, repoDir, baseSHA, headSHA, approvalSHA string) (*OutputData, []*gh.CurrentApproval, string) {
78+
t.Helper()
79+
80+
warnings := &bytes.Buffer{}
81+
info := &bytes.Buffer{}
82+
83+
realClient, err := gh.NewClient("test-owner", "test-repo", "test-token")
84+
if err != nil {
85+
t.Fatalf("failed to build the real client: %v", err)
86+
}
87+
realClient.SetWarningBuffer(warnings)
88+
realClient.SetInfoBuffer(info)
89+
90+
client := &realCheckApprovalsClient{
91+
mockGitHubClient: &mockGitHubClient{
92+
pr: &github.PullRequest{
93+
Number: github.Ptr(1),
94+
Base: &github.PullRequestBranch{SHA: github.Ptr(baseSHA)},
95+
Head: &github.PullRequestBranch{SHA: github.Ptr(headSHA)},
96+
User: &github.User{Login: github.Ptr("author")},
97+
},
98+
currentApprovals: []*gh.CurrentApproval{{
99+
GHLogin: codeowners.NewSlug("@reviewer"),
100+
ReviewID: 1,
101+
Reviewers: []codeowners.Slug{codeowners.NewSlug("@owner")},
102+
CommitID: approvalSHA,
103+
}},
104+
},
105+
real: realClient,
106+
}
107+
108+
app := &App{
109+
config: &Config{
110+
RepoDir: repoDir,
111+
PR: 1,
112+
Quiet: true,
113+
InfoBuffer: info,
114+
WarningBuffer: warnings,
115+
},
116+
client: client,
117+
}
118+
119+
output, err := app.Run()
120+
if err != nil {
121+
t.Fatalf("app.Run failed: %v\nwarnings: %s", err, warnings)
122+
}
123+
return output, client.dismissed, warnings.String()
124+
}
125+
126+
const retentionBaseSource = `package service
127+
128+
func Alpha() int {
129+
return 1
130+
}
131+
132+
func Beta() int {
133+
return 2
134+
}
135+
`
136+
137+
// retentionApprovedSource is the change the reviewer approved.
138+
const retentionApprovedSource = `package service
139+
140+
func Alpha() int {
141+
return 1
142+
}
143+
144+
func Beta() int {
145+
return 20
146+
}
147+
`
148+
149+
// Adds a comment and nothing else, so a comment is all the reviewer has not seen.
150+
const retentionHeadSource = `package service
151+
152+
// Alpha is the first step.
153+
func Alpha() int {
154+
return 1
155+
}
156+
157+
func Beta() int {
158+
return 20
159+
}
160+
`
161+
162+
// configBody is committed as codeowners.toml on the base ref, which is where the
163+
// application reads its configuration from.
164+
func buildCommentOnlyRepo(t *testing.T, configBody string) (repoDir, baseSHA, headSHA, approvalSHA string) {
165+
t.Helper()
166+
repoDir = t.TempDir()
167+
initRepo(t, repoDir)
168+
169+
writeRepoFile(t, repoDir, ".codeowners", "* @owner\n")
170+
writeRepoFile(t, repoDir, "codeowners.toml", configBody)
171+
writeRepoFile(t, repoDir, "service.go", retentionBaseSource)
172+
baseSHA = commitAll(t, repoDir, "base")
173+
174+
writeRepoFile(t, repoDir, "service.go", retentionApprovedSource)
175+
approvalSHA = commitAll(t, repoDir, "approved change")
176+
177+
writeRepoFile(t, repoDir, "service.go", retentionHeadSource)
178+
headSHA = commitAll(t, repoDir, "comment on top of the approved change")
179+
180+
return repoDir, baseSHA, headSHA, approvalSHA
181+
}
182+
183+
const retentionOffConfig = `disable_review_status_comments = true
184+
`
185+
186+
// The feature is inert until asked for: no section and an all-off section have to
187+
// produce the same bytes.
188+
func TestRunWithoutRetentionSectionIsUnchanged(t *testing.T) {
189+
const explicitlyOff = `disable_review_status_comments = true
190+
191+
[approval_retention]
192+
enabled = false
193+
whitespace = false
194+
comments = false
195+
formatting = false
196+
string_literals = false
197+
renames = false
198+
fetch_orphaned_approval = false
199+
`
200+
201+
repoDir, baseSHA, headSHA, approvalSHA := buildCommentOnlyRepo(t, retentionOffConfig)
202+
absentOutput, absentDismissed, absentWarnings := runApp(t, repoDir, baseSHA, headSHA, approvalSHA)
203+
204+
repoDir, baseSHA, headSHA, approvalSHA = buildCommentOnlyRepo(t, explicitlyOff)
205+
offOutput, offDismissed, offWarnings := runApp(t, repoDir, baseSHA, headSHA, approvalSHA)
206+
207+
if absentOutput.Message != offOutput.Message || absentOutput.Success != offOutput.Success {
208+
t.Errorf("expected identical results, got %+v and %+v", absentOutput, offOutput)
209+
}
210+
if !slices.Equal(absentOutput.StillRequired, offOutput.StillRequired) {
211+
t.Errorf("expected identical still required, got %v and %v", absentOutput.StillRequired, offOutput.StillRequired)
212+
}
213+
if len(absentDismissed) != len(offDismissed) {
214+
t.Errorf("expected identical dismissals, got %d and %d", len(absentDismissed), len(offDismissed))
215+
}
216+
if absentWarnings != offWarnings {
217+
t.Errorf("expected identical warnings, got %q and %q", absentWarnings, offWarnings)
218+
}
219+
// Both are the pre-feature behavior, not merely equal to each other.
220+
if len(absentDismissed) != 1 || absentOutput.Success {
221+
t.Errorf("expected the approval to be dismissed as it always was, got %d dismissals, success %t",
222+
len(absentDismissed), absentOutput.Success)
223+
}
224+
}

internal/config/config.go

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,21 @@ import (
88
)
99

1010
type Config struct {
11-
MaxReviews *int `toml:"max_reviews"`
12-
MinReviews *int `toml:"min_reviews"`
13-
UnskippableReviewers []string `toml:"unskippable_reviewers"`
14-
Ignore []string `toml:"ignore"`
15-
Enforcement *Enforcement `toml:"enforcement"`
16-
HighPriorityLabels []string `toml:"high_priority_labels"`
17-
AdminBypass *AdminBypass `toml:"admin_bypass"`
18-
DetailedReviewers bool `toml:"detailed_reviewers"`
19-
DisableSmartDismissal bool `toml:"disable_smart_dismissal"`
20-
RequireBothBranchReviewers bool `toml:"require_both_branch_reviewers"`
21-
SuppressUnownedWarning bool `toml:"suppress_unowned_warning"`
22-
AllowSelfApproval bool `toml:"allow_self_approval"`
23-
SelfApprovalViaTeams bool `toml:"self_approval_via_teams"`
24-
DisableReviewStatusComments bool `toml:"disable_review_status_comments"`
11+
MaxReviews *int `toml:"max_reviews"`
12+
MinReviews *int `toml:"min_reviews"`
13+
UnskippableReviewers []string `toml:"unskippable_reviewers"`
14+
Ignore []string `toml:"ignore"`
15+
Enforcement *Enforcement `toml:"enforcement"`
16+
HighPriorityLabels []string `toml:"high_priority_labels"`
17+
AdminBypass *AdminBypass `toml:"admin_bypass"`
18+
ApprovalRetention *ApprovalRetention `toml:"approval_retention"`
19+
DetailedReviewers bool `toml:"detailed_reviewers"`
20+
DisableSmartDismissal bool `toml:"disable_smart_dismissal"`
21+
RequireBothBranchReviewers bool `toml:"require_both_branch_reviewers"`
22+
SuppressUnownedWarning bool `toml:"suppress_unowned_warning"`
23+
AllowSelfApproval bool `toml:"allow_self_approval"`
24+
SelfApprovalViaTeams bool `toml:"self_approval_via_teams"`
25+
DisableReviewStatusComments bool `toml:"disable_review_status_comments"`
2526
}
2627

2728
type Enforcement struct {
@@ -34,6 +35,66 @@ type AdminBypass struct {
3435
AllowedUsers []string `toml:"allowed_users"`
3536
}
3637

38+
// ApprovalRetention lists the kinds of diff change which may retain an approval.
39+
// Every flag is opt-in, including Enabled, so upgrading never changes how a
40+
// repository's approvals behave.
41+
type ApprovalRetention struct {
42+
Enabled bool `toml:"enabled"`
43+
Whitespace *bool `toml:"whitespace"`
44+
Comments *bool `toml:"comments"`
45+
Formatting *bool `toml:"formatting"`
46+
StringLiterals *bool `toml:"string_literals"`
47+
Renames *bool `toml:"renames"`
48+
FetchOrphanedApproval *bool `toml:"fetch_orphaned_approval"`
49+
}
50+
51+
func (r *ApprovalRetention) WhitespaceEnabled() bool {
52+
if r == nil {
53+
return false
54+
}
55+
return r.enabled(r.Whitespace)
56+
}
57+
58+
func (r *ApprovalRetention) CommentsEnabled() bool {
59+
if r == nil {
60+
return false
61+
}
62+
return r.enabled(r.Comments)
63+
}
64+
65+
func (r *ApprovalRetention) FormattingEnabled() bool {
66+
if r == nil {
67+
return false
68+
}
69+
return r.enabled(r.Formatting)
70+
}
71+
72+
func (r *ApprovalRetention) StringLiteralsEnabled() bool {
73+
if r == nil {
74+
return false
75+
}
76+
return r.enabled(r.StringLiterals)
77+
}
78+
79+
func (r *ApprovalRetention) RenamesEnabled() bool {
80+
if r == nil {
81+
return false
82+
}
83+
return r.enabled(r.Renames)
84+
}
85+
86+
func (r *ApprovalRetention) FetchOrphanedApprovalEnabled() bool {
87+
if r == nil {
88+
return false
89+
}
90+
return r.enabled(r.FetchOrphanedApproval)
91+
}
92+
93+
// Enabled is a kill switch, not a default: turning it on retains nothing on its own.
94+
func (r *ApprovalRetention) enabled(flag *bool) bool {
95+
return r.Enabled && flag != nil && *flag
96+
}
97+
3798
func ReadConfig(path string, fileReader codeowners.FileReader) (*Config, error) {
3899
if !strings.HasSuffix(path, "/") {
39100
path += "/"
@@ -47,6 +108,7 @@ func ReadConfig(path string, fileReader codeowners.FileReader) (*Config, error)
47108
Enforcement: &Enforcement{Approval: false, FailCheck: true},
48109
HighPriorityLabels: []string{},
49110
AdminBypass: &AdminBypass{Enabled: false, AllowedUsers: []string{}},
111+
ApprovalRetention: &ApprovalRetention{Enabled: false},
50112
DetailedReviewers: false,
51113
SelfApprovalViaTeams: false,
52114
DisableSmartDismissal: false,
@@ -79,5 +141,8 @@ func ReadConfig(path string, fileReader codeowners.FileReader) (*Config, error)
79141
if config.AdminBypass == nil {
80142
config.AdminBypass = defaultConfig.AdminBypass
81143
}
144+
if config.ApprovalRetention == nil {
145+
config.ApprovalRetention = defaultConfig.ApprovalRetention
146+
}
82147
return config, nil
83148
}

0 commit comments

Comments
 (0)