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.ts7.4 KB · 256 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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
/**
 * 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, desc, eq } from "drizzle-orm";
import { db } from "../db";
import {
  branchProtection,
  branchRequiredChecks,
  gateRuns,
  prComments,
  workflowRuns,
  workflows,
} from "../db/schema";
import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
import { matchGlob } from "./environments";

export interface ProtectionEvalContext {
  aiApproved: boolean;
  humanApprovalCount: number;
  gateResultGreen: boolean;
  hasFailedGates: boolean;
  /** Names of checks whose latest run passed. Used by E6 required-checks. */
  passingCheckNames?: string[];
}

export interface ProtectionDecision {
  allowed: boolean;
  rule: BranchProtection | null;
  reasons: string[];
  missingChecks?: 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,
  requiredChecks: string[] = []
): 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}).`
    );
  }

  // E6 — required status checks matrix
  let missingChecks: string[] | undefined;
  if (requiredChecks.length > 0) {
    const passing = new Set(ctx.passingCheckNames || []);
    const missing = requiredChecks.filter((n) => !passing.has(n));
    if (missing.length > 0) {
      missingChecks = missing;
      reasons.push(
        `Branch protection '${rule.pattern}' requires these checks to pass: ${missing.join(", ")}.`
      );
    }
  }

  return {
    allowed: reasons.length === 0,
    rule,
    reasons,
    ...(missingChecks ? { missingChecks } : {}),
  };
}

/**
 * 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;
  }
}

// ---------------------------------------------------------------------------
// E6 — Required status checks matrix
// ---------------------------------------------------------------------------

/**
 * List required check names for a branch protection rule. Empty array when
 * nothing is required (the default; same semantics as "no matrix configured").
 */
export async function listRequiredChecks(
  branchProtectionId: string
): Promise<BranchRequiredCheck[]> {
  try {
    return await db
      .select()
      .from(branchRequiredChecks)
      .where(eq(branchRequiredChecks.branchProtectionId, branchProtectionId));
  } catch {
    return [];
  }
}

/**
 * Compute the set of check names that have a passing latest result for this
 * repo + commit. A "check" is either:
 *   - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
 *     gateName), or
 *   - a `workflow_runs` row where `status = 'success'` (matched by workflow
 *     name, joined through the workflows table).
 *
 * Passing names are aggregated across the last N rows to survive re-runs.
 */
export async function passingCheckNames(
  repositoryId: string,
  commitSha: string | null
): Promise<string[]> {
  const names = new Set<string>();

  try {
    const whereClause = commitSha
      ? and(
          eq(gateRuns.repositoryId, repositoryId),
          eq(gateRuns.commitSha, commitSha)
        )
      : eq(gateRuns.repositoryId, repositoryId);
    const gRows = await db
      .select({ name: gateRuns.gateName, status: gateRuns.status })
      .from(gateRuns)
      .where(whereClause)
      .orderBy(desc(gateRuns.createdAt))
      .limit(200);
    for (const r of gRows) {
      if (r.status === "passed" || r.status === "repaired") {
        names.add(r.name);
      }
    }
  } catch {
    // ignore
  }

  try {
    const whereWf = commitSha
      ? and(
          eq(workflowRuns.repositoryId, repositoryId),
          eq(workflowRuns.commitSha, commitSha)
        )
      : eq(workflowRuns.repositoryId, repositoryId);
    const wRows = await db
      .select({
        name: workflows.name,
        status: workflowRuns.status,
      })
      .from(workflowRuns)
      .innerJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
      .where(whereWf)
      .orderBy(desc(workflowRuns.createdAt))
      .limit(200);
    for (const r of wRows) {
      if (r.status === "success") {
        names.add(r.name);
      }
    }
  } catch {
    // ignore
  }

  return Array.from(names);
}