@@ -21,12 +21,14 @@ import ( |
| 21 | 21 | "errors" |
| 22 | 22 | "fmt" |
| 23 | 23 | "log/slog" |
| 24 | + "path/filepath" |
| 24 | 25 | "strings" |
| 25 | 26 | |
| 26 | 27 | "github.com/jackc/pgx/v5" |
| 27 | 28 | "github.com/jackc/pgx/v5/pgtype" |
| 28 | 29 | "github.com/jackc/pgx/v5/pgxpool" |
| 29 | 30 | |
| 31 | + "github.com/tenseleyFlow/shithub/internal/checks" |
| 30 | 32 | "github.com/tenseleyFlow/shithub/internal/issues" |
| 31 | 33 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 32 | 34 | "github.com/tenseleyFlow/shithub/internal/pulls/review" |
@@ -322,12 +324,14 @@ func Mergeability(ctx context.Context, deps Deps, gitDir string, prID int64) err |
| 322 | 324 | MergeableState: pullsdb.PrMergeableStateDirty, |
| 323 | 325 | }) |
| 324 | 326 | } |
| 325 | | - // Review gate. Need the issue's repo + author for the eval. |
| 327 | + // Composed gate: review (S23) + required-checks (S24). Either one |
| 328 | + // failing produces `blocked`. The two evaluators are independent; |
| 329 | + // each loads its own slice of the protection rule. |
| 326 | 330 | issue, err := issuesdb.New().GetIssueByID(ctx, deps.Pool, prID) |
| 327 | 331 | if err != nil { |
| 328 | 332 | return fmt.Errorf("load issue: %w", err) |
| 329 | 333 | } |
| 330 | | - gate, err := review.Evaluate(ctx, deps.Pool, review.GateInputs{ |
| 334 | + reviewGate, err := review.Evaluate(ctx, deps.Pool, review.GateInputs{ |
| 331 | 335 | RepoID: issue.RepoID, |
| 332 | 336 | BaseRef: pr.BaseRef, |
| 333 | 337 | PRIssueID: prID, |
@@ -335,9 +339,21 @@ func Mergeability(ctx context.Context, deps Deps, gitDir string, prID int64) err |
| 335 | 339 | if err != nil { |
| 336 | 340 | return fmt.Errorf("review gate: %w", err) |
| 337 | 341 | } |
| 342 | + requiredCheckNames, err := loadRequiredCheckNames(ctx, deps.Pool, issue.RepoID, pr.BaseRef) |
| 343 | + if err != nil { |
| 344 | + return fmt.Errorf("required-check rule lookup: %w", err) |
| 345 | + } |
| 346 | + checksGate, err := checks.EvaluateRequiredChecks(ctx, deps.Pool, checks.GateInputs{ |
| 347 | + RepoID: issue.RepoID, |
| 348 | + HeadSHA: pr.HeadOid, |
| 349 | + RequiredNames: requiredCheckNames, |
| 350 | + }) |
| 351 | + if err != nil { |
| 352 | + return fmt.Errorf("checks gate: %w", err) |
| 353 | + } |
| 338 | 354 | state := pullsdb.PrMergeableStateClean |
| 339 | 355 | mergeable := true |
| 340 | | - if !gate.Satisfied { |
| 356 | + if !reviewGate.Satisfied || !checksGate.Satisfied { |
| 341 | 357 | state = pullsdb.PrMergeableStateBlocked |
| 342 | 358 | mergeable = false |
| 343 | 359 | } |
@@ -348,6 +364,33 @@ func Mergeability(ctx context.Context, deps Deps, gitDir string, prID int64) err |
| 348 | 364 | }) |
| 349 | 365 | } |
| 350 | 366 | |
| 367 | +// loadRequiredCheckNames returns the `status_checks_required` list |
| 368 | +// from the longest-pattern-matching protection rule for `baseRef`. |
| 369 | +// Empty slice means no rule, no required checks. |
| 370 | +func loadRequiredCheckNames(ctx context.Context, pool *pgxpool.Pool, repoID int64, baseRef string) ([]string, error) { |
| 371 | + rules, err := reposdb.New().ListBranchProtectionRules(ctx, pool, repoID) |
| 372 | + if err != nil { |
| 373 | + return nil, err |
| 374 | + } |
| 375 | + var best reposdb.BranchProtectionRule |
| 376 | + bestLen := -1 |
| 377 | + for _, r := range rules { |
| 378 | + ok, _ := filepath.Match(r.Pattern, baseRef) |
| 379 | + if !ok { |
| 380 | + continue |
| 381 | + } |
| 382 | + if len(r.Pattern) > bestLen || |
| 383 | + (len(r.Pattern) == bestLen && r.Pattern < best.Pattern) { |
| 384 | + best = r |
| 385 | + bestLen = len(r.Pattern) |
| 386 | + } |
| 387 | + } |
| 388 | + if bestLen < 0 { |
| 389 | + return []string{}, nil |
| 390 | + } |
| 391 | + return best.StatusChecksRequired, nil |
| 392 | +} |
| 393 | + |
| 351 | 394 | // int64FromPg unwraps a pgtype.Int8; returns 0 when invalid. |
| 352 | 395 | func int64FromPg(p pgtype.Int8) int64 { |
| 353 | 396 | if !p.Valid { |