Skip to content

Commit 982acbd

Browse files
authored
Add checkboxes to required reviews comments (#28)
1 parent 86f081d commit 982acbd

File tree

9 files changed

+1759
-1339
lines changed

9 files changed

+1759
-1339
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better
44

55
[![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1)
66
[![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml)
7-
![Coverage](https://img.shields.io/badge/Coverage-83.6%25-brightgreen)
7+
![Coverage](https://img.shields.io/badge/Coverage-84.2%25-brightgreen)
88
[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
99
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
1010

internal/app/app.go

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"slices"
7+
"strings"
8+
"time"
9+
10+
owners "github.com/multimediallc/codeowners-plus/internal/config"
11+
"github.com/multimediallc/codeowners-plus/internal/git"
12+
gh "github.com/multimediallc/codeowners-plus/internal/github"
13+
"github.com/multimediallc/codeowners-plus/pkg/codeowners"
14+
f "github.com/multimediallc/codeowners-plus/pkg/functional"
15+
)
16+
17+
// Config holds the application configuration
18+
type Config struct {
19+
Token string
20+
RepoDir string
21+
PR int
22+
Repo string
23+
Verbose bool
24+
Quiet bool
25+
InfoBuffer io.Writer
26+
WarningBuffer io.Writer
27+
}
28+
29+
// App represents the application with its dependencies
30+
type App struct {
31+
Conf *owners.Config
32+
config *Config
33+
client gh.Client
34+
codeowners codeowners.CodeOwners
35+
gitDiff git.Diff
36+
}
37+
38+
// New creates a new App instance with the given configuration
39+
func New(cfg Config) (*App, error) {
40+
repoSplit := strings.Split(cfg.Repo, "/")
41+
if len(repoSplit) != 2 {
42+
return nil, fmt.Errorf("invalid repo name: %s", cfg.Repo)
43+
}
44+
owner := repoSplit[0]
45+
repo := repoSplit[1]
46+
47+
client := gh.NewClient(owner, repo, cfg.Token)
48+
app := &App{
49+
config: &cfg,
50+
client: client,
51+
}
52+
53+
return app, nil
54+
}
55+
56+
func (a *App) printDebug(format string, args ...interface{}) {
57+
if a.config.Verbose {
58+
_, _ = fmt.Fprintf(a.config.InfoBuffer, format, args...)
59+
}
60+
}
61+
62+
func (a *App) printWarn(format string, args ...interface{}) {
63+
_, _ = fmt.Fprintf(a.config.WarningBuffer, format, args...)
64+
}
65+
66+
// Run executes the application logic
67+
func (a *App) Run() (bool, string, error) {
68+
// Initialize PR
69+
if err := a.client.InitPR(a.config.PR); err != nil {
70+
return false, "", fmt.Errorf("InitPR Error: %v", err)
71+
}
72+
a.printDebug("PR: %d\n", a.client.PR().GetNumber())
73+
74+
// Read config
75+
conf, err := owners.ReadConfig(a.config.RepoDir)
76+
if err != nil {
77+
a.printWarn("Error reading codeowners.toml - using default config\n")
78+
}
79+
a.Conf = conf
80+
81+
// Setup diff context
82+
diffContext := git.DiffContext{
83+
Base: a.client.PR().Base.GetSHA(),
84+
Head: a.client.PR().Head.GetSHA(),
85+
Dir: a.config.RepoDir,
86+
IgnoreDirs: conf.Ignore,
87+
}
88+
89+
// Get the diff of the PR
90+
a.printDebug("Getting diff for %s...%s\n", diffContext.Base, diffContext.Head)
91+
gitDiff, err := git.NewDiff(diffContext)
92+
if err != nil {
93+
return false, "", fmt.Errorf("NewGitDiff Error: %v", err)
94+
}
95+
a.gitDiff = gitDiff
96+
97+
// Initialize codeowners
98+
codeOwners, err := codeowners.New(a.config.RepoDir, gitDiff.AllChanges(), a.config.WarningBuffer)
99+
if err != nil {
100+
return false, "", fmt.Errorf("NewCodeOwners Error: %v", err)
101+
}
102+
a.codeowners = codeOwners
103+
104+
// Set author
105+
author := fmt.Sprintf("@%s", a.client.PR().User.GetLogin())
106+
codeOwners.SetAuthor(author)
107+
108+
// Warn about unowned files
109+
for _, uFile := range codeOwners.UnownedFiles() {
110+
a.printWarn("WARNING: Unowned File: %s\n", uFile)
111+
}
112+
113+
// Print file owners if verbose
114+
if a.config.Verbose {
115+
a.printFileOwners(codeOwners)
116+
}
117+
118+
// Process approvals and reviewers
119+
return a.processApprovalsAndReviewers()
120+
}
121+
122+
func (a *App) processApprovalsAndReviewers() (bool, string, error) {
123+
message := ""
124+
// Get all required owners before filtering
125+
allRequiredOwners := a.codeowners.AllRequired()
126+
allRequiredOwnerNames := allRequiredOwners.Flatten()
127+
a.printDebug("All Required Owners: %s\n", allRequiredOwnerNames)
128+
129+
// Get optional reviewers
130+
allOptionalReviewerNames := a.codeowners.AllOptional().Flatten()
131+
allOptionalReviewerNames = f.Filtered(allOptionalReviewerNames, func(name string) bool {
132+
return !slices.Contains(allRequiredOwnerNames, name)
133+
})
134+
a.printDebug("All Optional Reviewers: %s\n", allOptionalReviewerNames)
135+
136+
// Initialize user reviewer map
137+
if err := a.client.InitUserReviewerMap(allRequiredOwnerNames); err != nil {
138+
return false, message, fmt.Errorf("InitUserReviewerMap Error: %v", err)
139+
}
140+
141+
// Get current approvals
142+
ghApprovals, err := a.client.GetCurrentReviewerApprovals()
143+
if err != nil {
144+
return false, message, fmt.Errorf("GetCurrentApprovals Error: %v", err)
145+
}
146+
a.printDebug("Current Approvals: %+v\n", ghApprovals)
147+
148+
// Process token owner approval if enabled
149+
var tokenOwnerApproval *gh.CurrentApproval
150+
if a.Conf.Enforcement.Approval {
151+
tokenOwnerApproval, err = a.processTokenOwnerApproval()
152+
if err != nil {
153+
return false, message, err
154+
}
155+
}
156+
157+
// Process approvals and dismiss stale ones
158+
validApprovalCount, err := a.processApprovals(ghApprovals)
159+
if err != nil {
160+
return false, message, err
161+
}
162+
163+
// Request reviews from required owners
164+
err = a.requestReviews()
165+
if err != nil {
166+
return false, message, err
167+
}
168+
169+
unapprovedOwners := a.codeowners.AllRequired()
170+
maxReviewsMet := false
171+
if a.Conf.MaxReviews != nil && *a.Conf.MaxReviews > 0 {
172+
if validApprovalCount >= *a.Conf.MaxReviews && len(f.Intersection(unapprovedOwners.Flatten(), a.Conf.UnskippableReviewers)) == 0 {
173+
maxReviewsMet = true
174+
}
175+
}
176+
177+
// Add comments to the PR if necessary
178+
err = a.addReviewStatusComment(allRequiredOwners, maxReviewsMet)
179+
if err != nil {
180+
return false, message, fmt.Errorf("failed to add review status comment: %w", err)
181+
}
182+
err = a.addOptionalCcComment(allOptionalReviewerNames)
183+
if err != nil {
184+
return false, message, fmt.Errorf("failed to add optional CC comment: %w", err)
185+
}
186+
187+
// Exit if there are any unapproved codeowner teams
188+
if len(unapprovedOwners) > 0 && !maxReviewsMet {
189+
// Return failed status if any codeowner team has not approved the PR
190+
unapprovedCommentString := unapprovedOwners.ToCommentString(false)
191+
if a.Conf.Enforcement.Approval && tokenOwnerApproval != nil {
192+
_ = a.client.DismissStaleReviews([]*gh.CurrentApproval{tokenOwnerApproval})
193+
}
194+
message = fmt.Sprintf(
195+
"FAIL: Codeowners reviews not satisfied\nStill required:\n%s",
196+
unapprovedCommentString,
197+
)
198+
return false, message, nil
199+
}
200+
201+
// Exit if there are not enough reviews
202+
if a.Conf.MinReviews != nil && *a.Conf.MinReviews > 0 {
203+
if validApprovalCount < *a.Conf.MinReviews {
204+
message = fmt.Sprintf("FAIL: Min Reviews not satisfied. Need %d, found %d", *a.Conf.MinReviews, validApprovalCount)
205+
return false, message, nil
206+
}
207+
}
208+
209+
message = "Codeowners reviews satisfied"
210+
if a.Conf.Enforcement.Approval && tokenOwnerApproval == nil {
211+
// Approve the PR since all codeowner teams have approved
212+
err = a.client.ApprovePR()
213+
if err != nil {
214+
return true, message, fmt.Errorf("ApprovePR Error: %v", err)
215+
}
216+
}
217+
return true, message, nil
218+
}
219+
220+
func (a *App) addReviewStatusComment(allRequiredOwners codeowners.ReviewerGroups, maxReviewsMet bool) error {
221+
// Comment on the PR with the codeowner teams required for review
222+
223+
if a.config.Quiet || len(allRequiredOwners) == 0 {
224+
a.printDebug("Skipping review status comment (disabled or no unapproved owners).\n")
225+
return nil
226+
}
227+
228+
var commentPrefix = "Codeowners approval required for this PR:\n"
229+
230+
hasHighPriority, err := a.client.IsInLabels(a.Conf.HighPriorityLabels)
231+
if err != nil {
232+
a.printWarn("WARNING: Error checking high priority labels: %v\n", err)
233+
} else if hasHighPriority {
234+
commentPrefix = "❗High Prio❗\n\n" + commentPrefix
235+
}
236+
237+
comment := commentPrefix + allRequiredOwners.ToCommentString(true)
238+
239+
if maxReviewsMet {
240+
comment += "\n\nThe PR has received the max number of required reviews. No further action is required."
241+
}
242+
243+
fiveDaysAgo := time.Now().AddDate(0, 0, -5)
244+
existingComment, existingFound, err := a.client.FindExistingComment(commentPrefix, &fiveDaysAgo)
245+
if err != nil {
246+
return fmt.Errorf("FindExistingComment Error: %v", err)
247+
}
248+
249+
if existingFound {
250+
if found, _ := a.client.IsInComments(comment, &fiveDaysAgo); found {
251+
// we don't need to update the comment
252+
return nil
253+
}
254+
a.printDebug("Updating existing review status comment\n")
255+
err = a.client.UpdateComment(existingComment, comment)
256+
if err != nil {
257+
return fmt.Errorf("UpdateComment Error: %v", err)
258+
}
259+
} else {
260+
a.printDebug("Adding new review status comment: %q\n", comment)
261+
err = a.client.AddComment(comment)
262+
if err != nil {
263+
return fmt.Errorf("AddComment Error: %v", err)
264+
}
265+
}
266+
267+
return nil
268+
}
269+
270+
func (a *App) addOptionalCcComment(allOptionalReviewerNames []string) error {
271+
// Add CC comment to the PR with the optional reviewers that have not already been mentioned in the PR comments
272+
273+
if a.config.Quiet || len(allOptionalReviewerNames) == 0 {
274+
return nil
275+
}
276+
277+
var isInCommentsError error
278+
viewersToPing := f.Filtered(allOptionalReviewerNames, func(name string) bool {
279+
if isInCommentsError != nil {
280+
return false
281+
}
282+
found, err := a.client.IsSubstringInComments(name, nil)
283+
if err != nil {
284+
a.printWarn("WARNING: Error checking comments for substring '%s': %v\n", name, err)
285+
isInCommentsError = err
286+
return false
287+
}
288+
return !found
289+
})
290+
291+
if isInCommentsError != nil {
292+
return fmt.Errorf("IsInComments Error: %v", isInCommentsError)
293+
}
294+
295+
// Add the CC comment if there are any viewers to ping
296+
if len(viewersToPing) > 0 {
297+
comment := fmt.Sprintf("cc %s", strings.Join(viewersToPing, " "))
298+
a.printDebug("Adding CC comment: %q\n", comment)
299+
err := a.client.AddComment(comment)
300+
if err != nil {
301+
return fmt.Errorf("AddComment Error: %v", err)
302+
}
303+
} else {
304+
a.printDebug("No new optional reviewers to CC.\n")
305+
}
306+
307+
return nil
308+
}
309+
310+
func (a *App) processTokenOwnerApproval() (*gh.CurrentApproval, error) {
311+
tokenOwner, err := a.client.GetTokenUser()
312+
if err != nil {
313+
a.printWarn("WARNING: You might be trying to use a bot as an Enforcement.Approval user," +
314+
" but this will not work due to GitHub CODEOWNERS not allowing bots as code owners." +
315+
" To use the Enforcement.Approval feature, the token must belong to a GitHub user account")
316+
317+
a.Conf.Enforcement.Approval = false
318+
return nil, nil
319+
}
320+
321+
tokenOwnerApproval, _ := a.client.FindUserApproval(tokenOwner)
322+
return tokenOwnerApproval, nil
323+
}
324+
325+
func (a *App) processApprovals(ghApprovals []*gh.CurrentApproval) (int, error) {
326+
fileReviewers := f.MapMap(a.codeowners.FileRequired(), func(reviewers codeowners.ReviewerGroups) []string { return reviewers.Flatten() })
327+
approvers, approvalsToDismiss := a.client.CheckApprovals(fileReviewers, ghApprovals, a.gitDiff)
328+
a.codeowners.ApplyApprovals(approvers)
329+
330+
if len(approvalsToDismiss) > 0 {
331+
a.printDebug("Dismissing Stale Approvals: %+v\n", approvalsToDismiss)
332+
if err := a.client.DismissStaleReviews(approvalsToDismiss); err != nil {
333+
return 0, fmt.Errorf("DismissStaleReviews Error: %v", err)
334+
}
335+
}
336+
337+
return len(ghApprovals) - len(approvalsToDismiss), nil
338+
}
339+
340+
func (a *App) requestReviews() error {
341+
if a.config.Quiet {
342+
return nil
343+
}
344+
345+
unapprovedOwners := a.codeowners.AllRequired()
346+
unapprovedOwnerNames := unapprovedOwners.Flatten()
347+
a.printDebug("Remaining Required Owners: %s\n", unapprovedOwnerNames)
348+
349+
currentlyRequestedOwners, err := a.client.GetCurrentlyRequested()
350+
if err != nil {
351+
return fmt.Errorf("GetCurrentlyRequested Error: %v", err)
352+
}
353+
a.printDebug("Currently Requested Owners: %s\n", currentlyRequestedOwners)
354+
355+
previousReviewers, err := a.client.GetAlreadyReviewed()
356+
if err != nil {
357+
return fmt.Errorf("GetAlreadyReviewed Error: %v", err)
358+
}
359+
a.printDebug("Already Reviewed Owners: %s\n", previousReviewers)
360+
361+
filteredOwners := unapprovedOwners.FilterOut(currentlyRequestedOwners...)
362+
filteredOwners = filteredOwners.FilterOut(previousReviewers...)
363+
filteredOwnerNames := filteredOwners.Flatten()
364+
365+
if len(filteredOwners) > 0 {
366+
a.printDebug("Requesting Reviews from: %s\n", filteredOwnerNames)
367+
if err := a.client.RequestReviewers(filteredOwnerNames); err != nil {
368+
return fmt.Errorf("RequestReviewers Error: %v", err)
369+
}
370+
}
371+
372+
return nil
373+
}
374+
375+
func (a *App) printFileOwners(codeOwners codeowners.CodeOwners) {
376+
fileRequired := codeOwners.FileRequired()
377+
a.printDebug("File Reviewers:\n")
378+
for file, reviewers := range fileRequired {
379+
a.printDebug("- %s: %+v\n", file, reviewers.Flatten())
380+
}
381+
fileOptional := codeOwners.FileOptional()
382+
a.printDebug("File Optional:\n")
383+
for file, reviewers := range fileOptional {
384+
a.printDebug("- %s: %+v\n", file, reviewers.Flatten())
385+
}
386+
}

0 commit comments

Comments
 (0)