Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
branch-protection.ts4.2 KB · 139 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
 * Block D5 — Branch-protection enforcement helpers.
 *
 * The `branch_protection` table lets owners configure per-pattern rules. Until
 * now those rules were mostly advisory — `runAllGateChecks` read the repo-
 * global `repoSettings` for enable flags, and the merge handler only rejected
 * on gate-level hard failures. This module:
 *
 *   1. Matches a branch name against the list of protection rules for a repo
 *      (supports `*` / `**` globs via shared matcher).
 *   2. Evaluates the matched rule against merge-time context (AI approval,
 *      human approvals, gate result) and returns a pass/fail decision with
 *      human-readable reasons.
 *
 * Kept minimal: no throwing, no side effects.
 */

import { and, eq } from "drizzle-orm";
import { db } from "../db";
import { branchProtection, prComments } from "../db/schema";
import type { BranchProtection } from "../db/schema";
import { matchGlob } from "./environments";

export interface ProtectionEvalContext {
  aiApproved: boolean;
  humanApprovalCount: number;
  gateResultGreen: boolean;
  hasFailedGates: boolean;
}

export interface ProtectionDecision {
  allowed: boolean;
  rule: BranchProtection | null;
  reasons: string[];
}

/**
 * Find the most specific branch-protection rule that matches `branch`.
 * Rules with exact string matches win over glob rules; among globs the first
 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
 */
export async function matchProtection(
  repositoryId: string,
  branch: string
): Promise<BranchProtection | null> {
  let rules: BranchProtection[];
  try {
    rules = await db
      .select()
      .from(branchProtection)
      .where(eq(branchProtection.repositoryId, repositoryId));
  } catch {
    return null;
  }
  if (!rules || rules.length === 0) return null;

  // Exact match wins.
  const exact = rules.find((r) => r.pattern === branch);
  if (exact) return exact;

  // Otherwise first glob match (deterministic order).
  const globs = rules
    .filter((r) => r.pattern.includes("*"))
    .sort((a, b) => a.pattern.localeCompare(b.pattern));
  for (const rule of globs) {
    if (matchGlob(branch, rule.pattern)) return rule;
  }
  return null;
}

/**
 * Evaluate a protection rule against merge-time context. Does not block on
 * a missing rule — callers can treat that as "no protection configured".
 */
export function evaluateProtection(
  rule: BranchProtection | null,
  ctx: ProtectionEvalContext
): ProtectionDecision {
  if (!rule) {
    return { allowed: true, rule: null, reasons: [] };
  }
  const reasons: string[] = [];

  if (rule.requireAiApproval && !ctx.aiApproved) {
    reasons.push(
      `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
    );
  }
  if (rule.requireGreenGates && ctx.hasFailedGates) {
    reasons.push(
      `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
    );
  }
  if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
    reasons.push(
      `Branch protection '${rule.pattern}' requires at least one human review approval.`
    );
  }
  if (
    rule.requiredApprovals > 0 &&
    ctx.humanApprovalCount < rule.requiredApprovals
  ) {
    reasons.push(
      `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
    );
  }

  return { allowed: reasons.length === 0, rule, reasons };
}

/**
 * Count human (non-AI) approving PR comments. "Approval" is defined as a
 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
 * should treat a zero here as "unknown", not "rejected".
 */
export async function countHumanApprovals(pullRequestId: string): Promise<number> {
  try {
    const comments = await db
      .select({ body: prComments.body, isAi: prComments.isAiReview })
      .from(prComments)
      .where(
        and(
          eq(prComments.pullRequestId, pullRequestId),
          eq(prComments.isAiReview, false)
        )
      );
    return comments.filter((c) => {
      const b = (c.body || "").toLowerCase();
      return (
        b.includes("lgtm") ||
        b.includes(":+1:") ||
        b.includes("approved") ||
        b.includes("👍")
      );
    }).length;
  } catch {
    return 0;
  }
}