Skip to content

Commit eeee60e

Browse files
feat: recover an approval whose commit no longer resolves locally
When a branch is rebased or force-pushed, the commit an approval points at stops being reachable from any local ref. ChangesSince then fails on `git diff base...<approvalSHA>`, and the approval lands in badApprovals with no ownership check at all. That dismissal is not "your files changed", it is "I could not tell, so I reset you". GitHub still serves the orphaned object. With `fetch_orphaned_approval` on, a ref which cannot be resolved locally is fetched from origin once and the diff retried, so the approval is judged on its diff rather than lost to a rewritten branch. Fail-safe: if the fetch or the retry fails, the original diff error stays the cause and the approval is dismissed exactly as before. Hardening: - The fetch is bounded at 60s. It is the only git call here that waits on a remote; every other one is local and returns promptly. - The ref is passed after a `--` so a ref beginning with a dash cannot be read as an option. `git fetch` accepts --upload-pack, which names a command to run. - The original diff error is preserved as the wrapped cause, with any fetch or retry failure appended. Opt-in only, and default off so that enabling it is always a deliberate choice to add network calls to a run.
1 parent 86a9bc8 commit eeee60e

6 files changed

Lines changed: 540 additions & 11 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ self_approval_via_teams = false
254254
# Optional reviewers are still invited with a CC comment.
255255
disable_review_status_comments = false
256256

257+
# `fetch_orphaned_approval` (default false) recovers an approval whose commit no
258+
# longer resolves locally, which is what a rebase or a force-push leaves behind.
259+
# Without it that approval is dismissed with no ownership check at all, because the
260+
# diff it would be judged on cannot be computed. Off by default: it is the only
261+
# setting here that adds a network call, bounded at 60s per unresolvable commit.
262+
fetch_orphaned_approval = false
263+
257264
# `enforcement` allows you to specify how the Codeowners Plus check should be enforced
258265
[enforcement]
259266
# see "Enforcement Options" below for more details

internal/app/app.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,11 @@ func (a *App) Run() (*OutputData, error) {
131131

132132
// Get the diff of the PR
133133
a.printDebug("Getting diff for %s...%s\n", diffContext.Base, diffContext.Head)
134-
gitDiff, err := git.NewDiff(diffContext)
134+
var diffOpts []git.DiffOption
135+
if conf.FetchOrphanedApproval {
136+
diffOpts = append(diffOpts, git.WithFetchOrphanedRefs())
137+
}
138+
gitDiff, err := git.NewDiff(diffContext, diffOpts...)
135139
if err != nil {
136140
return &OutputData{}, fmt.Errorf("NewGitDiff Error: %v", err)
137141
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package app
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/google/go-github/v89/github"
12+
"github.com/multimediallc/codeowners-plus/internal/git"
13+
gh "github.com/multimediallc/codeowners-plus/internal/github"
14+
"github.com/multimediallc/codeowners-plus/pkg/codeowners"
15+
)
16+
17+
// The shared mock approves everything without reading the diff, which is the
18+
// decision under test, so the real staleness check is spliced back in.
19+
type realCheckApprovalsClient struct {
20+
*mockGitHubClient
21+
real gh.Client
22+
dismissed []*gh.CurrentApproval
23+
}
24+
25+
func (c *realCheckApprovalsClient) CheckApprovals(
26+
fileReviewerMap map[string][]string,
27+
approvals []*gh.CurrentApproval,
28+
originalDiff git.Diff,
29+
) ([]codeowners.Slug, []*gh.CurrentApproval) {
30+
return c.real.CheckApprovals(fileReviewerMap, approvals, originalDiff)
31+
}
32+
33+
func (c *realCheckApprovalsClient) DismissStaleReviews(approvals []*gh.CurrentApproval) error {
34+
c.dismissed = append(c.dismissed, approvals...)
35+
return c.mockGitHubClient.DismissStaleReviews(approvals)
36+
}
37+
38+
func runGit(t *testing.T, dir string, args ...string) string {
39+
t.Helper()
40+
cmd := exec.Command("git", args...)
41+
cmd.Dir = dir
42+
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
43+
out, err := cmd.CombinedOutput()
44+
if err != nil {
45+
t.Fatalf("git %s (in %s): %v\n%s", strings.Join(args, " "), dir, err, out)
46+
}
47+
return strings.TrimSpace(string(out))
48+
}
49+
50+
func initRepo(t *testing.T, dir string) {
51+
t.Helper()
52+
runGit(t, dir, "init", "-q", "-b", "main")
53+
runGit(t, dir, "config", "user.email", "test@example.invalid")
54+
runGit(t, dir, "config", "user.name", "Test User")
55+
runGit(t, dir, "config", "commit.gpgsign", "false")
56+
}
57+
58+
func writeRepoFile(t *testing.T, dir, name, content string) {
59+
t.Helper()
60+
path := filepath.Join(dir, name)
61+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
62+
t.Fatalf("mkdir for %s: %v", name, err)
63+
}
64+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
65+
t.Fatalf("write %s: %v", name, err)
66+
}
67+
}
68+
69+
func commitAll(t *testing.T, dir, message string) string {
70+
t.Helper()
71+
runGit(t, dir, "add", "-A")
72+
runGit(t, dir, "commit", "-q", "-m", message)
73+
return runGit(t, dir, "rev-parse", "HEAD")
74+
}
75+
76+
func runApp(t *testing.T, repoDir, baseSHA, headSHA, approvalSHA string) (*OutputData, []*gh.CurrentApproval, string) {
77+
t.Helper()
78+
79+
warnings := &bytes.Buffer{}
80+
info := &bytes.Buffer{}
81+
82+
realClient, err := gh.NewClient("test-owner", "test-repo", "test-token")
83+
if err != nil {
84+
t.Fatalf("failed to build the real client: %v", err)
85+
}
86+
realClient.SetWarningBuffer(warnings)
87+
realClient.SetInfoBuffer(info)
88+
89+
client := &realCheckApprovalsClient{
90+
mockGitHubClient: &mockGitHubClient{
91+
pr: &github.PullRequest{
92+
Number: github.Ptr(1),
93+
Base: &github.PullRequestBranch{SHA: github.Ptr(baseSHA)},
94+
Head: &github.PullRequestBranch{SHA: github.Ptr(headSHA)},
95+
User: &github.User{Login: github.Ptr("author")},
96+
},
97+
currentApprovals: []*gh.CurrentApproval{{
98+
GHLogin: codeowners.NewSlug("@reviewer"),
99+
ReviewID: 1,
100+
Reviewers: []codeowners.Slug{codeowners.NewSlug("@owner")},
101+
CommitID: approvalSHA,
102+
}},
103+
},
104+
real: realClient,
105+
}
106+
107+
app := &App{
108+
config: &Config{
109+
RepoDir: repoDir,
110+
PR: 1,
111+
Quiet: true,
112+
InfoBuffer: info,
113+
WarningBuffer: warnings,
114+
},
115+
client: client,
116+
}
117+
118+
output, err := app.Run()
119+
if err != nil {
120+
t.Fatalf("app.Run failed: %v\nwarnings: %s", err, warnings)
121+
}
122+
return output, client.dismissed, warnings.String()
123+
}
124+
125+
const orphanBaseSource = `package service
126+
127+
func Gamma() int {
128+
return 3
129+
}
130+
`
131+
132+
const orphanApprovedSource = `package service
133+
134+
func Gamma() int {
135+
return 30
136+
}
137+
`
138+
139+
// The approved commit exists only on the remote, as a force-push leaves it, and the
140+
// head edits a file the reviewer does not own: the approval survives iff the diff does.
141+
func buildOrphanedApprovalRepo(t *testing.T, configBody string) (repoDir, baseSHA, headSHA, approvalSHA string) {
142+
t.Helper()
143+
repoDir = t.TempDir()
144+
initRepo(t, repoDir)
145+
146+
writeRepoFile(t, repoDir, ".codeowners", "service.go @owner\n")
147+
writeRepoFile(t, repoDir, "codeowners.toml", configBody)
148+
writeRepoFile(t, repoDir, "service.go", orphanBaseSource)
149+
writeRepoFile(t, repoDir, "notes.md", "first note\n")
150+
baseSHA = commitAll(t, repoDir, "base")
151+
152+
// Grown on the remote, where the local repository never sees it.
153+
originDir := t.TempDir()
154+
runGit(t, repoDir, "clone", "-q", repoDir, originDir)
155+
initRepo(t, originDir)
156+
runGit(t, originDir, "config", "uploadpack.allowAnySHA1InWant", "true")
157+
writeRepoFile(t, originDir, "service.go", orphanApprovedSource)
158+
approvalSHA = commitAll(t, originDir, "approved change")
159+
160+
writeRepoFile(t, repoDir, "service.go", orphanApprovedSource)
161+
writeRepoFile(t, repoDir, "notes.md", "first note\nsecond note\n")
162+
headSHA = commitAll(t, repoDir, "approved change plus an unowned edit")
163+
runGit(t, repoDir, "remote", "add", "origin", originDir)
164+
165+
return repoDir, baseSHA, headSHA, approvalSHA
166+
}
167+
168+
// The config file alone decides whether an approval whose commit exists only on the
169+
// remote is recovered or dismissed.
170+
func TestRunFetchesOrphanedApproval(t *testing.T) {
171+
const fetchOff = `disable_review_status_comments = true
172+
suppress_unowned_warning = true
173+
`
174+
const fetchOn = `disable_review_status_comments = true
175+
suppress_unowned_warning = true
176+
fetch_orphaned_approval = true
177+
`
178+
179+
tt := []struct {
180+
name string
181+
config string
182+
expectDismissed bool
183+
}{
184+
{name: "fetch disabled, approval dismissed", config: fetchOff, expectDismissed: true},
185+
{name: "fetch enabled, approval recovered", config: fetchOn, expectDismissed: false},
186+
}
187+
188+
for _, tc := range tt {
189+
t.Run(tc.name, func(t *testing.T) {
190+
repoDir, baseSHA, headSHA, approvalSHA := buildOrphanedApprovalRepo(t, tc.config)
191+
192+
// Guard the premise: if the commit resolved locally the fetch would have
193+
// nothing to recover and both cases would pass for the wrong reason.
194+
cmd := exec.Command("git", "cat-file", "-e", approvalSHA)
195+
cmd.Dir = repoDir
196+
if err := cmd.Run(); err == nil {
197+
t.Fatalf("approval commit %s should not be present locally", approvalSHA)
198+
}
199+
200+
output, dismissed, warnings := runApp(t, repoDir, baseSHA, headSHA, approvalSHA)
201+
202+
if tc.expectDismissed {
203+
if len(dismissed) != 1 {
204+
t.Errorf("expected the approval to be dismissed, got %d dismissals", len(dismissed))
205+
}
206+
if !strings.Contains(warnings, "Error getting changes since") {
207+
t.Errorf("expected a warning about the unresolvable ref, got %q", warnings)
208+
}
209+
if output.Success {
210+
t.Error("expected the run to fail without the approval")
211+
}
212+
return
213+
}
214+
215+
if len(dismissed) != 0 {
216+
t.Errorf("expected the approval to survive, got %d dismissals: %s", len(dismissed), warnings)
217+
}
218+
if !output.Success {
219+
t.Errorf("expected the run to succeed, got %q (warnings: %s)", output.Message, warnings)
220+
}
221+
})
222+
}
223+
}

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ type Config struct {
2222
AllowSelfApproval bool `toml:"allow_self_approval"`
2323
SelfApprovalViaTeams bool `toml:"self_approval_via_teams"`
2424
DisableReviewStatusComments bool `toml:"disable_review_status_comments"`
25+
// A rebase or force-push can leave the commit an approval points at
26+
// unreachable locally. Off by default because it is the only setting here
27+
// which adds a network call to a run.
28+
FetchOrphanedApproval bool `toml:"fetch_orphaned_approval"`
2529
}
2630

2731
type Enforcement struct {

0 commit comments

Comments
 (0)