@@ -14,6 +14,7 @@ import ( |
| 14 | 14 | |
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/billing" |
| 17 | 18 | "github.com/tenseleyFlow/shithub/internal/entitlements" |
| 18 | 19 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 19 | 20 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
@@ -192,30 +193,84 @@ func (h *Handlers) settingsBranchesUpsert(w http.ResponseWriter, r *http.Request |
| 192 | 193 | http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/branches?notice=saved", http.StatusSeeOther) |
| 193 | 194 | } |
| 194 | 195 | |
| 196 | +// branchProtectionEntitlementNotice gates the required-reviewers |
| 197 | +// and advanced-branch-protection knobs on private repos. PRO05 |
| 198 | +// extended it to personal private repos in report-only mode — a |
| 199 | +// user-kind would-deny logs an "entitlements.report_only_deny" |
| 200 | +// event but does not block the save. PRO07 flips per-feature |
| 201 | +// enforce on for user kind once production telemetry confirms no |
| 202 | +// existing Free user is over-quota. |
| 203 | +// |
| 204 | +// Public repos and non-private personal/org repos return empty |
| 205 | +// (no gating). |
| 195 | 206 | func (h *Handlers) branchProtectionEntitlementNotice(ctx context.Context, row reposdb.Repo, requiredReviews int, dismissStale bool, requiredChecks []string, dismissStaleChecks bool) (string, error) { |
| 196 | | - if !row.OwnerOrgID.Valid || row.Visibility != reposdb.RepoVisibilityPrivate { |
| 207 | + if row.Visibility != reposdb.RepoVisibilityPrivate { |
| 197 | 208 | return "", nil |
| 198 | 209 | } |
| 199 | | - if requiredReviews > 0 || dismissStale { |
| 200 | | - decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, row.OwnerOrgID.Int64, entitlements.FeatureOrgRequiredReviewers) |
| 201 | | - if err != nil { |
| 202 | | - return "", err |
| 210 | + principal, ok := principalFromRepo(row) |
| 211 | + if !ok { |
| 212 | + return "", nil |
| 203 | 213 | } |
| 204 | | - if !decision.Allowed { |
| 205 | | - return branchProtectionNoticeCode(decision, true), nil |
| 214 | + if requiredReviews > 0 || dismissStale { |
| 215 | + code, err := h.evaluateBranchProtectionFeature(ctx, principal, entitlements.FeatureRequiredReviewers, true) |
| 216 | + if err != nil || code != "" { |
| 217 | + return code, err |
| 206 | 218 | } |
| 207 | 219 | } |
| 208 | 220 | if len(requiredChecks) > 0 || dismissStaleChecks { |
| 209 | | - decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, row.OwnerOrgID.Int64, entitlements.FeatureOrgAdvancedBranchProtection) |
| 221 | + code, err := h.evaluateBranchProtectionFeature(ctx, principal, entitlements.FeatureAdvancedBranchProtection, false) |
| 222 | + if err != nil || code != "" { |
| 223 | + return code, err |
| 224 | + } |
| 225 | + } |
| 226 | + return "", nil |
| 227 | +} |
| 228 | + |
| 229 | +// principalFromRepo returns the billing principal that owns the |
| 230 | +// repo. Personal repos resolve to SubjectKindUser; org-owned to |
| 231 | +// SubjectKindOrg. Returns ok=false when neither owner column is |
| 232 | +// populated (shouldn't happen for a valid repo row). |
| 233 | +func principalFromRepo(row reposdb.Repo) (billing.Principal, bool) { |
| 234 | + if row.OwnerOrgID.Valid { |
| 235 | + return billing.PrincipalForOrg(row.OwnerOrgID.Int64), true |
| 236 | + } |
| 237 | + if row.OwnerUserID.Valid { |
| 238 | + return billing.PrincipalForUser(row.OwnerUserID.Int64), true |
| 239 | + } |
| 240 | + return billing.Principal{}, false |
| 241 | +} |
| 242 | + |
| 243 | +// evaluateBranchProtectionFeature checks the feature for the given |
| 244 | +// principal. Returns the notice code (empty when allowed or |
| 245 | +// report-only-allowed); a non-empty code means the save is blocked |
| 246 | +// and the caller redirects to the settings page with a banner. |
| 247 | +// |
| 248 | +// Report-only semantics: user-kind would-denies log via Logger and |
| 249 | +// return empty notice. Org-kind would-denies return the SP05 |
| 250 | +// notice code. |
| 251 | +func (h *Handlers) evaluateBranchProtectionFeature(ctx context.Context, p billing.Principal, feature entitlements.Feature, requiredReviewers bool) (string, error) { |
| 252 | + if !entitlements.FeatureAppliesToKind(feature, p.Kind) { |
| 253 | + return "", nil |
| 254 | + } |
| 255 | + decision, err := entitlements.CheckPrincipalFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, p, feature) |
| 210 | 256 | if err != nil { |
| 211 | 257 | return "", err |
| 212 | 258 | } |
| 213 | | - if !decision.Allowed { |
| 214 | | - return branchProtectionNoticeCode(decision, false), nil |
| 215 | | - } |
| 259 | + if decision.Allowed { |
| 260 | + return "", nil |
| 216 | 261 | } |
| 262 | + if p.IsUser() { |
| 263 | + h.d.Logger.InfoContext(ctx, "entitlements.report_only_deny", |
| 264 | + "principal", p.String(), |
| 265 | + "principal_kind", string(p.Kind), |
| 266 | + "principal_id", p.ID, |
| 267 | + "feature", string(feature), |
| 268 | + "reason", string(decision.Reason), |
| 269 | + "required_plan", string(decision.RequiredPlan)) |
| 217 | 270 | return "", nil |
| 218 | 271 | } |
| 272 | + return branchProtectionNoticeCode(decision, requiredReviewers), nil |
| 273 | +} |
| 219 | 274 | |
| 220 | 275 | func branchProtectionNoticeCode(decision entitlements.Decision, requiredReviewers bool) string { |
| 221 | 276 | if requiredReviewers { |