|
| 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