Comparing changes

Choose two branches to see what's changed or to start a new pull request.

base: scratch
Choose a base ref
compare: s50/cross-cutting
Choose a head ref
Create pull request
Able to merge. These branches can be automatically merged.
34 commits 104 files changed 3 contributors

Commits on s50/cross-cutting

.env.examplemodified
9 lines changed — click to load
@@ -47,3 +47,9 @@ SHITHUB_AUTH__SMTP__ADDR=127.0.0.1:1025
4747
 # AEAD key for at-rest TOTP secrets (S06). Generate once and persist —
4848
 # rotating without re-encrypting every row breaks every existing 2FA login.
4949
 # SHITHUB_TOTP_KEY=$(openssl rand -base64 32)
50
+
51
+# ----- rate limits (S50 §0) -----
52
+# Per-hour budgets for /api/v1/* requests. Authed keyed by token id;
53
+# anon keyed by remote IP. Zero falls back to the default.
54
+SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=5000
55
+SHITHUB_RATELIMIT__API__ANON_PER_HOUR=60
CHANGELOG.mdmodified
29 lines changed — click to load
@@ -10,7 +10,28 @@ between minor releases.
1010
 
1111
 ## [Unreleased]
1212
 
13
-(Empty — first post-launch entries land here.)
13
+### Added
14
+
15
+- **REST API contract (S50 §0).** `GET /api/v1/meta` returns the
16
+  server's version stamp and a list of feature capability strings
17
+  for client-side feature detection. Every `/api/v1/*` response
18
+  now carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
19
+  `X-RateLimit-Reset`, and (when PAT-authenticated) `X-OAuth-Scopes`.
20
+  The 403 scope-reject response also carries
21
+  `X-Accepted-OAuth-Scopes`. Operators tune the API rate-limit
22
+  budgets via `ratelimit.api.authed_per_hour` /
23
+  `ratelimit.api.anon_per_hour` (defaults: 5000 / 60).
24
+- **Pagination helper** `internal/web/handlers/api/apipage` —
25
+  emits canonical RFC 8288 Link headers (`first`/`prev`/`next`/`last`)
26
+  with absolute URLs rooted at the configured public base URL.
27
+
28
+### Changed
29
+
30
+- **JSON error envelope on `/api/v1/*`.** `401` and `403`
31
+  responses now emit `{"error": "..."}` with
32
+  `Content-Type: application/json` (previously `text/plain`).
33
+  Existing `4xx`/`5xx` responses from the handler bodies are
34
+  unchanged.
1435
 
1536
 ## [0.1.0] — TBD (operator fills in cutover date)
1637
 
Makefilemodified
25 lines changed — click to load
@@ -2,7 +2,7 @@
22
 # Targets mirror what CI runs. The Makefile is the source of truth.
33
 
44
 .DEFAULT_GOAL := help
5
-.PHONY: help dev build test test-race lint lint-policy lint-markdown lint-secret-logs lint-spdx lint-unused lint-migrations verify-api-docs fmt tidy clean ci assets install-tools version deploy deploy-check restore-drill bench-staging docs docs-serve docs-verify gen-third-party-notices audit-a11y audit-a11y-pa11y audit-a11y-axe load-test
5
+.PHONY: help dev build test test-race lint lint-policy lint-markdown lint-org-plan lint-secret-logs lint-spdx lint-unused lint-migrations verify-api-docs fmt tidy clean ci assets install-tools version deploy deploy-check restore-drill bench-staging docs docs-serve docs-verify gen-third-party-notices audit-a11y audit-a11y-pa11y audit-a11y-axe load-test
66
 
77
 # Build metadata embedded into the binary via -ldflags.
88
 VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
@@ -72,7 +72,7 @@ assets: ## Copy Primer CSS into internal/web/static/ for embedding.
7272
 		echo "warn: .refs/primer-css/dist not found; run 'git clone https://github.com/primer/css .refs/primer-css' first"; \
7373
 	fi
7474
 
75
-ci: lint lint-policy lint-markdown lint-secret-logs lint-spdx lint-unused lint-migrations verify-api-docs test build ## Full CI pipeline (matches .github/workflows/ci.yml).
75
+ci: lint lint-policy lint-markdown lint-org-plan lint-secret-logs lint-spdx lint-unused lint-migrations verify-api-docs test build ## Full CI pipeline (matches .github/workflows/ci.yml).
7676
 	@echo "ci: ok"
7777
 
7878
 lint-policy: ## Enforce policy-package boundary (no inline auth checks in handlers/git/cmd).
@@ -81,6 +81,9 @@ lint-policy: ## Enforce policy-package boundary (no inline auth checks in handle
8181
 lint-markdown: ## Enforce markdown-package boundary (no goldmark/bluemonday outside internal/markdown).
8282
 	@scripts/lint-markdown-boundary.sh
8383
 
84
+lint-org-plan: ## Enforce paid org entitlement boundary (no direct orgs.plan feature gates).
85
+	@scripts/lint-org-plan-boundary.sh
86
+
8487
 lint-secret-logs: ## Fail when source emits log lines containing token-prefix patterns.
8588
 	@scripts/lint-secret-logs.sh
8689
 
bench/fixtures/README.mdmodified
11 lines changed — click to load
@@ -38,3 +38,11 @@ fixtures used by `make bench-small`. That's enough to catch
3838
 regressions in the harness itself plus the small-scale handler
3939
 latency floor; the big-fixture targets in S36's "Definition of
4040
 done" land with the generators above.
41
+
42
+## Actions smoke fixture
43
+
44
+`actions/smoke-run-only.yml` is the canonical first end-to-end Actions
45
+workflow fixture. Copy it into `.shithub/workflows/smoke.yml` in a
46
+repository with a registered `ubuntu-latest` runner. It intentionally uses only
47
+`run:` steps because the current executor rejects reserved `uses:` aliases
48
+until checkout and artifact execution are implemented.
bench/fixtures/actions/smoke-run-only.ymladded
10 lines changed — click to load
@@ -0,0 +1,10 @@
1
+name: smoke
2
+on: [push, workflow_dispatch]
3
+jobs:
4
+  hello:
5
+    runs-on: ubuntu-latest
6
+    env:
7
+      RUN_ID: ${{ shithub.run_id }}
8
+    steps:
9
+      - run: echo "hello from shithub actions"
10
+      - run: test -n "$RUN_ID"
cmd/shithubd/admin_actions.gomodified
136 lines changed — click to load
@@ -3,13 +3,20 @@
33
 package main
44
 
55
 import (
6
+	"context"
67
 	"encoding/json"
8
+	"errors"
79
 	"fmt"
810
 	"os"
11
+	"time"
912
 
1013
 	"github.com/spf13/cobra"
1114
 
15
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
16
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
1217
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
18
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
19
+	"github.com/tenseleyFlow/shithub/internal/infra/db"
1320
 )
1421
 
1522
 // adminActionsCmd is the parent group for actions-related operator
@@ -80,7 +87,116 @@ Exit code 0 = clean parse, 2 = Error-severity diagnostics produced,
8087
 	},
8188
 }
8289
 
90
+func newAdminActionsCancelAllCmd() *cobra.Command {
91
+	var repoID int64
92
+	var limit int
93
+	var dryRun bool
94
+	var confirm bool
95
+
96
+	cmd := &cobra.Command{
97
+		Use:   "cancel-all",
98
+		Short: "Request cancellation for active Actions workflow runs",
99
+		Long: `Requests cancellation for queued/running Actions workflow runs.
100
+
101
+By default this scans all repositories, oldest first. Use --repo-id to scope
102
+the operation to one repository. Running jobs receive cancel_requested=true and
103
+are killed by their runner's cancel-check loop; queued jobs become terminal
104
+immediately.
105
+
106
+This is an operator break-glass command. Run with --dry-run first, then repeat
107
+with --confirm to mutate state.`,
108
+		Args: cobra.NoArgs,
109
+		RunE: func(cmd *cobra.Command, _ []string) error {
110
+			if repoID < 0 {
111
+				return errors.New("admin actions cancel-all: --repo-id must be zero or positive")
112
+			}
113
+			if limit < 1 || limit > 5000 {
114
+				return errors.New("admin actions cancel-all: --limit must be between 1 and 5000")
115
+			}
116
+			if !dryRun && !confirm {
117
+				return errors.New("admin actions cancel-all: refusing to mutate without --confirm; use --dry-run to inspect")
118
+			}
119
+
120
+			cfg, err := config.Load(nil)
121
+			if err != nil {
122
+				return err
123
+			}
124
+			if cfg.DB.URL == "" {
125
+				return errors.New("admin actions cancel-all: DB not configured (set SHITHUB_DATABASE_URL)")
126
+			}
127
+			ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
128
+			defer cancel()
129
+
130
+			pool, err := db.Open(ctx, db.Config{
131
+				URL:            cfg.DB.URL,
132
+				MaxConns:       2,
133
+				MinConns:       0,
134
+				ConnectTimeout: cfg.DB.ConnectTimeout,
135
+			})
136
+			if err != nil {
137
+				return fmt.Errorf("admin actions cancel-all: db open: %w", err)
138
+			}
139
+			defer pool.Close()
140
+
141
+			q := actionsdb.New()
142
+			runs, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{
143
+				RepoID:     repoID,
144
+				LimitCount: int32(limit),
145
+			})
146
+			if err != nil {
147
+				return fmt.Errorf("admin actions cancel-all: list active runs: %w", err)
148
+			}
149
+
150
+			out := cmd.OutOrStdout()
151
+			scope := "all repositories"
152
+			if repoID != 0 {
153
+				scope = fmt.Sprintf("repo_id=%d", repoID)
154
+			}
155
+			if dryRun {
156
+				for _, run := range runs {
157
+					_, _ = fmt.Fprintf(out,
158
+						"would cancel: run_id=%d repo_id=%d run_index=%d status=%s workflow=%s ref=%s sha=%s\n",
159
+						run.ID, run.RepoID, run.RunIndex, run.Status, run.WorkflowFile, run.HeadRef, run.HeadSha)
160
+				}
161
+				_, _ = fmt.Fprintf(out, "cancel-all dry-run: found %d active run(s) in %s (limit=%d)\n",
162
+					len(runs), scope, limit)
163
+				return nil
164
+			}
165
+
166
+			cancelledRuns := 0
167
+			cancelledJobs := 0
168
+			completedRuns := 0
169
+			for _, run := range runs {
170
+				result, err := actionslifecycle.CancelRun(ctx, actionslifecycle.Deps{Pool: pool}, run.ID, actionslifecycle.CancelReasonUser)
171
+				if err != nil {
172
+					return fmt.Errorf("admin actions cancel-all: cancel run %d: %w", run.ID, err)
173
+				}
174
+				if len(result.ChangedJobs) > 0 {
175
+					cancelledRuns++
176
+				}
177
+				cancelledJobs += len(result.ChangedJobs)
178
+				if result.RunCompleted {
179
+					completedRuns++
180
+				}
181
+				_, _ = fmt.Fprintf(out,
182
+					"cancelled: run_id=%d repo_id=%d run_index=%d changed_jobs=%d run_completed=%t\n",
183
+					run.ID, run.RepoID, run.RunIndex, len(result.ChangedJobs), result.RunCompleted)
184
+			}
185
+			_, _ = fmt.Fprintf(out,
186
+				"cancel-all: scanned %d active run(s) in %s; changed_runs=%d changed_jobs=%d completed_runs=%d\n",
187
+				len(runs), scope, cancelledRuns, cancelledJobs, completedRuns)
188
+			return nil
189
+		},
190
+	}
191
+	cmd.Flags().Int64Var(&repoID, "repo-id", 0, "Repository id to scope cancellation to; 0 scans all repositories")
192
+	cmd.Flags().IntVar(&limit, "limit", 500, "Maximum active runs to scan")
193
+	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print matching active runs without mutating state")
194
+	cmd.Flags().BoolVar(&confirm, "confirm", false, "Confirm cancellation of matching active runs")
195
+	return cmd
196
+}
197
+
83198
 func init() {
84199
 	adminActionsCmd.AddCommand(adminActionsParseCmd)
200
+	adminActionsCmd.AddCommand(newAdminActionsCancelAllCmd())
85201
 	adminCmd.AddCommand(adminActionsCmd)
86202
 }
docs/internal/actions-schema.mdmodified
68 lines changed — click to load
@@ -130,19 +130,25 @@ v1 supports four triggers — anything else is a parse error.
130130
 
131131
 ### `uses:` allowlist
132132
 
133
-Exactly three aliases, no exceptions:
133
+Exactly three aliases are reserved at parse time, no exceptions:
134134
 
135
-| Alias                            | What it does                              |
136
-| -------------------------------- | ----------------------------------------- |
137
-| `actions/checkout@v4`            | Clones the repo into the workspace        |
138
-| `shithub/upload-artifact@v1`     | Uploads files to `workflow_artifacts`     |
139
-| `shithub/download-artifact@v1`   | Pulls artifacts back in a downstream job  |
135
+| Alias                            | Parser status | Runner status                              |
136
+| -------------------------------- | ------------- | ------------------------------------------ |
137
+| `actions/checkout@v4`            | accepted      | rejected until checkout support lands      |
138
+| `shithub/upload-artifact@v1`     | accepted      | rejected until artifact upload lands       |
139
+| `shithub/download-artifact@v1`   | accepted      | rejected until artifact download lands     |
140140
 
141141
 Any other `uses:` value (community actions, Docker images, composite
142142
 actions) is an Error-severity diagnostic. The marketplace problem is
143143
 explicitly out of scope for v1; revisit only if a real demand exists
144144
 and we have an answer for supply-chain trust.
145145
 
146
+The current Docker executor runs `run:` steps only. It fails a reserved
147
+`uses:` alias deliberately instead of pretending checkout/artifact
148
+semantics exist. This keeps the first end-to-end smoke path honest:
149
+`run:`-only workflows are executable now, while repository checkout and
150
+artifact transfer remain explicit follow-up work.
151
+
146152
 ### File-size + parser caps
147153
 
148154
 - **64 KB** workflow file size cap (`workflow.MaxWorkflowFileBytes`).
@@ -562,6 +568,29 @@ defer to S41g where the lifecycle work touches that surface anyway.
562568
   ref defaults to the repo's default branch). Returns 204 No Content
563569
   on success. Synchronous trigger.Enqueue (no discovery — file is
564570
   named in the URL). Auth: requires repo write.
571
+- `GET /{owner}/{repo}/actions.atom`
572
+  Returns the last 50 workflow runs as an Atom feed. Auth and visibility
573
+  match the Actions tab (`repo:read`). Entries link to
574
+  `/{owner}/{repo}/actions/runs/{run_index}` and include the workflow
575
+  name/path, event, branch, short SHA, status, and conclusion.
576
+
577
+### Webhook events (S41h)
578
+
579
+Actions emits webhook-facing domain events through `notif.EmitTx` on
580
+state transitions:
581
+
582
+- `workflow_run`, with `payload.action` set to `queued`, `running`, or
583
+  `completed` (`completed` may carry `conclusion:"cancelled"`).
584
+- `workflow_job`, with `payload.action` set to `queued`, `running`,
585
+  `completed`, or `cancelled`.
586
+
587
+Payloads are structural snapshots only. They include ids, run index,
588
+workflow path/name, head SHA/ref, event kind, status, conclusion,
589
+timestamps, job key/name/runner id, needs, timeout, and cancellation
590
+state. They deliberately exclude `workflow_runs.event_payload`, env,
591
+permissions, logs, runner JWTs, and secret values. This keeps the
592
+webhook surface stable without turning arbitrary workflow input into
593
+subscriber-facing data.
565594
 
566595
 ### What S41b deliberately doesn't do
567596
 
@@ -572,8 +601,6 @@ defer to S41g where the lifecycle work touches that surface anyway.
572601
   but no caller produces them yet. S41b-2 adds the sweep + the
573602
   `robfig/cron/v3` dep + `shithubd-cron.service` wiring.
574603
 - External-PR triggers. Conservative collaborator gate above.
575
-- `workflow_run` webhook events. S41h adds the webhook event family
576
-  + atom feed.
577604
 
578605
 ## Secrets + variables settings surface (S41c)
579606
 
docs/internal/billing.mdadded
201 lines changed — click to load
@@ -0,0 +1,201 @@
1
+# Billing and paid organizations
2
+
3
+shithub's first paid surface is organization billing. The code does not
4
+ship billing yet; this document records the product and engineering
5
+contract that the PAYMENTS sprint series implements.
6
+
7
+The current implementation already has the important shape for paid
8
+organizations: `orgs.plan` is an enum with `free`, `team`, and
9
+`enterprise`; organizations own repositories; organization members and
10
+teams exist; branch protection and PR review gates exist; Actions has
11
+schema for org/repo secrets, variables, and artifacts. Billing must
12
+turn that substrate into a fair hosted service without taxing
13
+public/open-source collaboration.
14
+
15
+## Product contract
16
+
17
+As of 2026-05-12, GitHub's public pricing page presents Free at
18
+`$0`, Team at `$4/user/month`, and Enterprise starting at
19
+`$21/user/month`. shithub follows the same mental model but removes
20
+Copilot/AI promises from the paid-org offering.
21
+
22
+Initial decisions:
23
+
24
+- Free organizations remain self-serve.
25
+- Team is `$4` per active organization member per month.
26
+- Active organization members, including owners, count as paid seats.
27
+- Team has no launch trial.
28
+- Enterprise is a visible contact-sales stub, not self-serve.
29
+- Stripe Billing is the first payment processor.
30
+- PayPal, manual invoices, SAML, SCIM, LDAP, enterprise account
31
+  hierarchy, and contracts are deferred.
32
+
33
+The fairness rule is explicit: public/open-source collaboration should
34
+stay generous. Paid gates focus on private collaboration, hosted cost,
35
+advanced organization controls, and support expectations.
36
+
37
+## Pricing copy rules
38
+
39
+Pricing and onboarding pages must describe only features shithub can
40
+actually deliver on the hosted service. Before changing pricing copy,
41
+refresh the official GitHub pricing source and the Stripe Billing docs
42
+because both are time-sensitive inputs.
43
+
44
+Rules for paid-org copy:
45
+
46
+- Do not mention Copilot, AI agents, AI code review, or AI quotas.
47
+- Do not promise SAML, SCIM, LDAP, managed users, audit exports, data
48
+  residency, compliance attestations, contracts, or custom support
49
+  until the matching implementation sprint ships.
50
+- Do not advertise Packages, Pages, Wikis, Projects, Actions minutes,
51
+  or storage quotas until those surfaces have enforcement and usage
52
+  accounting.
53
+- Use upgrade language for unavailable Team features instead of hiding
54
+  existing data. Downgrades preserve configuration and make gated
55
+  settings read-only where possible.
56
+- Keep public/open-source collaboration generous in both copy and
57
+  enforcement. Avoid copy that makes public repositories feel like a
58
+  second-class Free tier.
59
+- Enterprise is a contact-sales stub in v1. It should collect interest
60
+  without promising contractual features.
61
+
62
+## Entitlement matrix
63
+
64
+| Capability | Free | Team | Enterprise stub |
65
+| --- | --- | --- | --- |
66
+| Public org repositories | Included | Included | Contact sales |
67
+| Basic private org repositories | Included | Included | Contact sales |
68
+| Org members and invitations | Included | Billed by active member | Contact sales |
69
+| Visible teams | Included | Included | Contact sales |
70
+| Secret teams | Upgrade | Included | Contact sales |
71
+| Basic branch protection | Included | Included | Contact sales |
72
+| Advanced private-repo branch protection | Upgrade | Included | Contact sales |
73
+| Required reviewers on private org repos | Upgrade | Included | Contact sales |
74
+| CODEOWNERS review | Deferred | Deferred | Deferred |
75
+| Org-level Actions secrets | Upgrade | Included | Contact sales |
76
+| Org-level Actions variables | Upgrade | Included | Contact sales |
77
+| Actions minutes | Low quota once metered | Higher quota once metered | Contact sales |
78
+| Actions artifacts/storage | Low quota once metered | Higher quota once metered | Contact sales |
79
+| Packages storage | Deferred until Packages is active | Deferred until Packages is active | Deferred |
80
+| Pages/Wikis/Projects | Do not promise until shipped | Do not promise until shipped | Deferred |
81
+| Audit log export | Deferred | Deferred | Later Enterprise feature |
82
+| SAML/SCIM/managed users | Deferred | Deferred | Later Enterprise feature |
83
+| Data residency/compliance | Deferred | Deferred | Later Enterprise feature |
84
+| Billing support | Basic instance support | Billing support after runbook exists | Contact sales |
85
+
86
+## Current capability audit
87
+
88
+Already present and safe to gate:
89
+
90
+- Organizations with `plan` and `billing_email`.
91
+- Organization members, owner role, and invitations.
92
+- Teams, including `privacy='secret'`.
93
+- Branch protection rules and required review counts.
94
+- PR review and reviewer-request substrate.
95
+- Org/repo Actions secrets and variables schema.
96
+
97
+Present but missing enforcement or metering:
98
+
99
+- Storage quota type exists, but quota persistence and enforcement are
100
+  incomplete.
101
+- Actions minutes, artifacts, and object usage need accounting before
102
+  paid limits can be promised.
103
+- Packages storage cannot be sold until the Packages sprint is active
104
+  and quota enforcement exists.
105
+
106
+Deferred:
107
+
108
+- SAML, SCIM, LDAP, enterprise account hierarchy, audit-log export, data
109
+  residency, compliance promises, and custom support SLAs.
110
+- Copilot/AI features are intentionally outside shithub's paid-org
111
+  product.
112
+
113
+## Billing architecture
114
+
115
+Stripe is the payment source of truth. shithub is the entitlement source
116
+of truth.
117
+
118
+The billing implementation should add a local billing domain that stores
119
+only Stripe IDs and payment summaries, never card data. Webhooks update
120
+local subscription state after signature verification. Policy and
121
+request handlers read local billing/entitlement state and must not call
122
+Stripe in hot paths.
123
+
124
+Required local concepts:
125
+
126
+- Stripe customer per billable organization.
127
+- Subscription state per organization.
128
+- Subscription item ID for seat quantity sync.
129
+- Immutable webhook receipts with unique provider event IDs.
130
+- Invoice/payment summaries for UI.
131
+- Seat snapshots for auditability.
132
+- Billing grace/lock state derived from processed subscription events.
133
+
134
+PAYMENTS SP02 adds these as local database tables:
135
+
136
+- `org_billing_states` stores the organization billing projection used
137
+  by entitlement checks.
138
+- `billing_seat_snapshots` records active and billable seat counts over
139
+  time.
140
+- `billing_invoices` stores invoice/payment summaries for billing UI.
141
+- `billing_webhook_events` stores immutable provider event receipts for
142
+  idempotent webhook processing.
143
+
144
+New organizations receive a Free billing state from a database trigger,
145
+and the migration backfills existing organizations as Free. Subscription
146
+snapshot writes also keep `orgs.plan` synchronized as the
147
+human-facing summary.
148
+
149
+## Entitlement architecture
150
+
151
+Paid feature checks must live behind a central entitlement package, not
152
+as scattered `orgs.plan` checks in handlers.
153
+
154
+`make lint-org-plan` enforces this boundary. Schema/sqlc plumbing may
155
+store and scan the plan value, but product behavior should ask the
156
+entitlement package whether a feature key is available.
157
+
158
+Expected feature keys:
159
+
160
+- `org.secret_teams`
161
+- `org.advanced_branch_protection`
162
+- `org.required_reviewers`
163
+- `org.actions_org_secrets`
164
+- `org.actions_org_variables`
165
+- `org.private_collaboration_limit`
166
+- `org.storage_quota`
167
+- `org.actions_minutes_quota`
168
+
169
+Authorization and entitlement are separate gates. A user must have both
170
+the policy permission and the paid entitlement for gated writes. Denials
171
+must preserve existing `policy.Maybe404` behavior where existence leaks
172
+matter.
173
+
174
+## Downgrade behavior
175
+
176
+Downgrades must preserve customer data. Moving from Team to Free should
177
+not delete teams, secrets, variables, branch rules, or review settings.
178
+Existing gated resources become read-only where possible. Users can
179
+remove gated configuration, but cannot create or expand it until the
180
+organization upgrades again.
181
+
182
+## Open questions for implementation
183
+
184
+- Whether Free should limit private org collaborators before usage
185
+  metering exists, or whether the first paid gates are advanced controls
186
+  only.
187
+- Whether required reviewers are gated only for private org repos. The
188
+  current lean is private-org-only.
189
+- Whether org-level Actions secrets and variables should be Team-only
190
+  even for public repositories. The current lean is yes for org scope.
191
+- Exact Free and Team quota numbers for Actions and storage. These must
192
+  come from real host-cost estimates before SP08.
193
+
194
+## Source references
195
+
196
+- GitHub pricing: `https://github.com/pricing`
197
+- GitHub plans docs:
198
+  `https://docs.github.com/en/get-started/learning-about-github/githubs-plans`
199
+- Stripe Billing: `https://docs.stripe.com/billing`
200
+- Stripe pricing models:
201
+  `https://docs.stripe.com/products-prices/pricing-models`
docs/internal/branch-protection.mdmodified
25 lines changed — click to load
@@ -10,6 +10,7 @@ hook.
1010
 | ------------------------------------------------------- | ----------------------------- |
1111
 | `GET /{owner}/{repo}/branches?filter=active|stale|`       | `branchesList`                |
1212
 | `GET /{owner}/{repo}/tags`                              | `tagsList`                    |
13
+| `GET /{owner}/{repo}/compare`                           | `compareView`                 |
1314
 | `GET /{owner}/{repo}/compare/{base}...{head}`           | `compareView`                 |
1415
 | `GET /{owner}/{repo}/settings/branches`                 | `settingsBranches` (auth-gated) |
1516
 | `POST /{owner}/{repo}/settings/branches`                | upsert rule                   |
@@ -50,10 +51,14 @@ first-class releases ship post-MVP.
5051
 ## Compare view
5152
 
5253
 Inputs: `base` and `head` (defaults to `repo.default_branch` when
53
-empty). Renders:
54
-
55
-- A summary line: ahead/behind counts plus a "Create pull request"
56
-  button when `head` has commits not on `base`.
54
+empty). The bare `/compare` route renders GitHub's branch/tag picker
55
+blank slate instead of redirecting to `default...default`. Renders:
56
+
57
+- Base/head dropdowns listing local branches and tags with live
58
+  filtering. Cross-repo `fork:branch` input is still normalized to a
59
+  local ref until fork PRs ship.
60
+- A mergeability/status line plus a "Create pull request" button when
61
+  `head` has commits not on `base`.
5762
 - The commits-list (head-side only) via
5863
   `repogit.CommitsBetween(base, head, 250)`.
5964
 - The three-dot diff via S19's renderer fed from
docs/internal/index.mdmodified
8 lines changed — click to load
@@ -58,6 +58,8 @@ site.
5858
 - [actions-schema.md](./actions-schema.md),
5959
   [actions-runner-api.md](./actions-runner-api.md)
6060
 - [orgs.md](./orgs.md), [teams.md](./teams.md)
61
+- [billing.md](./billing.md) — paid org product contract,
62
+  entitlements, and Stripe integration guardrails.
6163
 - [notifications.md](./notifications.md)
6264
 - [search.md](./search.md), [markdown.md](./markdown.md)
6365
 - [seo.md](./seo.md) — crawler endpoints, metadata, sitemap, and
docs/internal/orgs.mdmodified
18 lines changed — click to load
@@ -182,6 +182,18 @@ Soft-deleted users/orgs are dropped from `principals` so their slug
182182
 becomes available — the username_redirects table still preserves the
183183
 old slug for 301s during the rename cooldown.
184184
 
185
+## Billing posture
186
+
187
+Organizations are the first planned paid shithub surface. The
188
+`orgs.plan` and `billing_email` fields are present today, but payment
189
+processing and entitlement enforcement live in the PAYMENTS sprint
190
+series. The durable product and implementation contract is tracked in
191
+[`billing.md`](./billing.md).
192
+
193
+Until that series lands, production code must not branch on
194
+`orgs.plan` for feature access. Paid feature checks should go through
195
+the future entitlement package described in the billing doc.
196
+
185197
 ## What we deferred from the spec
186198
 
187199
 * **`username_redirects` rename to `principal_redirects`**. The
docs/internal/permissions.mdmodified
53 lines changed — click to load
@@ -107,6 +107,30 @@ the verdict. Ordered from most-decisive to least:
107107
 10. **Login-required actions** (star/fork) on anonymous → deny
108108
    (`DenyAnonymous`).
109109
 
110
+## Authorization versus entitlements
111
+
112
+`policy.Can` answers only one question: is this actor allowed to
113
+perform this action on this resource under shithub's permission model?
114
+It must not decide whether an organization has paid for a feature.
115
+
116
+Paid organization checks are a second gate after authorization. The
117
+expected flow for gated writes is:
118
+
119
+1. Load the resource and run the normal policy check.
120
+2. Preserve `policy.Maybe404` behavior for private-resource denials.
121
+3. Ask the entitlement layer whether the organization has the specific
122
+   feature key.
123
+4. If the feature is unavailable, return a billing/upgrade response
124
+   without re-deriving ownership, visibility, role, or plan state in
125
+   the handler.
126
+
127
+The entitlement layer may inspect billing state and plan-derived
128
+features. Policy code, handlers, git transports, and domain packages
129
+must not branch directly on `orgs.plan` or sqlc `OrgPlan*` constants.
130
+That keeps security authorization independent from commercial product
131
+packaging, and makes downgrades/grace periods possible without
132
+rewriting role checks.
133
+
110134
 ## Existence-leak guard
111135
 
112136
 `policy.Maybe404(decision, repo, actor)` maps a denial to a status
@@ -147,10 +171,10 @@ that constructs an actor must source it correctly:
147171
   suspending an account takes effect on the user's next click.
148172
 * **Web (PAT)** — `middleware.PATAuthMiddleware` rejects requests
149173
   whose owning user has `suspended_at IS NOT NULL` with a 401 before
150
-  the handler runs. Code paths under PAT auth construct
151
-  `policy.UserActor(..., IsSuspended: false, ...)` because the gate
152
-  is upstream; the field is still passed for honesty and is correct
153
-  by construction.
174
+  the handler runs. It still binds username, suspension, and site-admin
175
+  fields into `middleware.PATAuth`; API policy gates must construct
176
+  actors through `PATAuth.PolicyActor()` so the request actor stays
177
+  honest even as the middleware evolves.
154178
 * **git over HTTPS (`internal/web/handlers/githttp`)** — the basic-
155179
   auth resolver (`auth.go::resolveViaPAT`/`resolveViaPassword`)
156180
   rejects suspended owners with `errBadCredentials` *before* the
@@ -212,3 +236,9 @@ the policy actor at the lookup wrapper):
212236
 Test files everywhere are exempt — they legitimately seed state. If a
213237
 new pattern surfaces (e.g. an issue handler reads `issue.author_id`),
214238
 extend the script accordingly.
239
+
240
+`scripts/lint-org-plan-boundary.sh` also runs in `make ci`. It fails on
241
+direct plan feature gates outside `internal/billing/`,
242
+`internal/entitlements/`, generated sqlc models, migrations, and tests.
243
+When adding a paid feature, add or use an entitlement feature key rather
244
+than comparing `OrgPlanTeam` or `OrgPlanEnterprise` at the call site.
docs/internal/pull-requests.mdmodified
30 lines changed — click to load
@@ -52,8 +52,12 @@ so a self-merge can't be opened. Cross-fork PRs land in S27.
5252
 | `POST /{owner}/{repo}/pulls/{number}/ready`           | RequireUser   |
5353
 | `POST /{owner}/{repo}/pulls/{number}/merge`           | RequireUser   |
5454
 
55
-The compare view (S20) links into `/pulls/new?base=...&head=...` so
56
-the entry point matches GitHub's flow.
55
+The pull-request list's "New pull request" button starts at
56
+`/{owner}/{repo}/compare`, where the user picks base/head refs. Once
57
+the head is ahead of base, compare links into
58
+`/pulls/new?base=...&head=...`. `/pulls/new` redirects back to
59
+compare when no head ref is supplied so the GitHub-style branch picker
60
+remains the canonical entry point.
5761
 
5862
 ## Auto-synchronize on head push
5963
 
@@ -148,6 +152,16 @@ noreply emails are post-MVP.
148152
 
149153
 ## Web UI
150154
 
155
+- Compare/new-PR entry follows GitHub's range editor: base and head
156
+  dropdowns list branches and tags with live filtering, preserve the
157
+  opposite side of the comparison, and render compare URLs with the
158
+  `base...head` shape.
159
+- The open-PR page reuses the compare state: ahead/behind counts,
160
+  mergeability probe, commits, and three-dot diff all render before
161
+  submission. The form posts the selected refs as hidden fields.
162
+- The new PR description uses the shared GitHub-like Markdown editor
163
+  (write/preview, toolbar, mentions/references/saved replies shell).
164
+  Copilot suggestions are intentionally omitted.
151165
 - Tabbed view at `/pulls/{number}` switches between Conversation,
152166
   Commits, Files, Checks via the `Tab` field on the template data.
153167
 - Conversation follows GitHub's PageHeader + tab strip shape: state
docs/internal/runbooks/actions.mdmodified
149 lines changed — click to load
@@ -1,5 +1,72 @@
11
 # Actions runbook
22
 
3
+This is the operator runbook for shithub Actions. Host provisioning lives in
4
+[runner-deploy.md](./runner-deploy.md), and runner protocol details live in
5
+[actions-runner-api.md](../actions-runner-api.md).
6
+
7
+## Shape
8
+
9
+```text
10
+git push / workflow_dispatch / schedule / pull_request
11
+        |
12
+        v
13
+workflow:trigger worker job
14
+        |
15
+        v
16
+workflow_runs + workflow_jobs + workflow_steps + check_runs
17
+        |
18
+        v
19
+registered runner heartbeat claims a matching queued job
20
+        |
21
+        v
22
+containerized run: steps -> log chunks -> step/job status -> run rollup
23
+```
24
+
25
+The v1 executor supports containerized `run:` steps. The parser reserves
26
+`actions/checkout@v4`, `shithub/upload-artifact@v1`, and
27
+`shithub/download-artifact@v1`, but the Docker runner rejects `uses:` steps
28
+until checkout metadata and artifact transfer are wired end to end. Do not use
29
+`actions/checkout@v4` in production smoke workflows yet.
30
+
31
+## First smoke
32
+
33
+1. Confirm migrations are applied and the web process can enqueue workers.
34
+2. Register one runner with a label that matches the workflow:
35
+
36
+```sh
37
+shithubd admin runner register \
38
+  --name smoke-runner-1 \
39
+  --labels self-hosted,linux,ubuntu-latest \
40
+  --capacity 1
41
+```
42
+
43
+3. Start `shithubd-runner` with the printed token. For production hosts, use
44
+   the Ansible/systemd path in [runner-deploy.md](./runner-deploy.md).
45
+4. Push a `run:`-only workflow:
46
+
47
+```yaml
48
+name: smoke
49
+on: [push, workflow_dispatch]
50
+jobs:
51
+  hello:
52
+    runs-on: ubuntu-latest
53
+    env:
54
+      RUN_ID: ${{ shithub.run_id }}
55
+    steps:
56
+      - run: echo "hello from shithub actions"
57
+      - run: test -n "$RUN_ID"
58
+```
59
+
60
+5. Expected result:
61
+
62
+- `workflow:trigger` enqueues a workflow run.
63
+- A runner heartbeat claims the queued job within one idle poll interval.
64
+- The Actions run page streams step logs while the job is running.
65
+- The matching check run completes with `success`.
66
+- `/{owner}/{repo}/actions.atom` includes the completed run.
67
+
68
+Repeat with `exit 1`; the check should complete with `failure`.
69
+
370
 ## Live log tail
471
 
572
 Step log pages open an SSE stream at:
@@ -13,6 +80,11 @@ The stream sends `event: chunk` records with the chunk sequence as the SSE
1380
 `?after=<seq>` for the first connection from a rendered log page. A terminal
1481
 step sends `event: done` and closes the stream.
1582
 
83
+In `shithubd`, this route is mounted outside the normal app compression and
84
+30-second timeout middleware. If a future route move puts live logs back under
85
+either middleware, EventSource clients will churn and logs can buffer despite
86
+the Caddy flush setting.
87
+
1688
 Log chunks are never sent through Postgres `NOTIFY`. Runner log writes append
1789
 to `workflow_step_log_chunks`, then `NOTIFY step_log_<step_id>` with only the
1890
 sequence number. Step completion notifies `done`.
@@ -39,3 +111,66 @@ contains that route and reload Caddy:
39111
 ```sh
40112
 sudo caddy reload --config /etc/caddy/Caddyfile
41113
 ```
114
+
115
+## Runner health
116
+
117
+On the runner host:
118
+
119
+```sh
120
+systemctl status shithubd-runner
121
+journalctl -u shithubd-runner -n 100 --no-pager
122
+```
123
+
124
+On the app host, inspect runner registration and heartbeat state:
125
+
126
+```sh
127
+shithubd admin actions runner list
128
+```
129
+
130
+Important metrics:
131
+
132
+- `shithub_actions_runner_heartbeats_total{result="claimed|no_job"}`
133
+- `shithub_actions_runner_jwt_total{result="issued|rejected|replay"}`
134
+- `shithub_actions_jobs_cancelled_total{reason="user|concurrency|timeout"}`
135
+- `shithub_actions_log_scrub_replacements_total{location="server"}`
136
+- `shithub_actions_step_timeouts_total`
137
+
138
+## Emergency cancel
139
+
140
+Start with a dry run:
141
+
142
+```sh
143
+shithubd admin actions cancel-all --dry-run --limit 100
144
+```
145
+
146
+Scope to one repository when possible:
147
+
148
+```sh
149
+shithubd admin actions cancel-all --dry-run --repo-id 42
150
+```
151
+
152
+Then confirm:
153
+
154
+```sh
155
+shithubd admin actions cancel-all --confirm --repo-id 42
156
+```
157
+
158
+Queued jobs are marked cancelled immediately. Running jobs receive
159
+`cancel_requested=true`; the runner sees that through `/cancel-check`, kills the
160
+active container, and reports terminal `cancelled`.
161
+
162
+## Common failures
163
+
164
+- **Run never appears:** confirm the workflow file is under
165
+  `.shithub/workflows/`, parse it with `shithubd admin actions parse <file>`,
166
+  and verify the trigger event matches `on:`.
167
+- **Run stays queued:** confirm a runner is registered with matching labels and
168
+  capacity, then inspect runner journal output and heartbeat metrics.
169
+- **Step logs buffer:** verify the Caddy route above and confirm the SSE route
170
+  is still mounted outside compression and short timeouts.
171
+- **`uses:` step fails:** expected for now. Replace with a `run:` step until
172
+  checkout/artifact support lands.
173
+- **Secrets appear masked inconsistently:** check
174
+  `shithub_actions_log_scrub_replacements_total{location="server"}` and confirm
175
+  the job was claimed after the secret was created or rotated. Mask snapshots
176
+  are captured at claim time.
docs/internal/security-checklist.mdmodified
7 lines changed — click to load
@@ -35,6 +35,7 @@ document.
3535
 | Org suspension blocks writes | `policy.Can` + `DenyOrgSuspended` | S30 |
3636
 | Repo soft-delete blocks all actions | `policy.Can` + `DenyRepoDeleted` | S15 |
3737
 | Author-self-close on issues/PRs | `policy.Can` author branch | S21/S22 |
38
+| Paid org feature gates stay behind entitlements | `scripts/lint-org-plan-boundary.sh` in `make ci` | PAYMENTS SP01 |
3839
 
3940
 ## Input handling
4041
 
docs/internal/social.mdmodified
12 lines changed — click to load
@@ -46,7 +46,11 @@ The signed-in `/explore` feed includes:
4646
 - public org-scoped activity for followed orgs.
4747
 
4848
 Anonymous Explore uses the global public feed. Both feeds page with a
49
-keyset cursor over `(created_at, id)`.
49
+keyset cursor over `(created_at, id)`. Full-page requests with a
50
+`before` cursor still render that cursor window for no-JavaScript
51
+fallbacks; HTMX requests return only the next feed rows plus a replacement
52
+`More` control, so the browser appends activity in place like GitHub's
53
+dashboard feed.
5054
 
5155
 ## Event Kinds
5256
 
docs/public/SUMMARY.mdmodified
15 lines changed — click to load
@@ -13,6 +13,7 @@
1313
 - [Issues](./user/issues.md)
1414
 - [Pull requests](./user/pull-requests.md)
1515
   - [Branch protection & reviews](./user/branch-protection.md)
16
+- [Actions](./user/actions.md)
1617
 - [Notifications](./user/notifications.md)
1718
 - [Webhooks](./user/webhooks.md)
1819
 - [Search](./user/search.md)
@@ -28,6 +29,8 @@
2829
 - [Issues](./api/issues.md)
2930
 - [Pull requests](./api/pulls.md)
3031
 - [Status checks](./api/checks.md)
32
+- [Actions workflow API](./api/actions.md)
33
+- [Actions runner API](./api/actions-runner.md)
3134
 - [Webhooks](./api/webhooks.md)
3235
 - [Search](./api/search.md)
3336
 - [Admin (site-admin only)](./api/admin.md)
docs/public/api/actions.mdadded
65 lines changed — click to load
@@ -0,0 +1,65 @@
1
+# Actions workflow API
2
+
3
+Actions workflow lifecycle endpoints are PAT-authenticated and require
4
+`repo:write`. The token's user must also have write permission on the
5
+repository that owns the target run or job.
6
+
7
+## Runs Atom feed
8
+
9
+```text
10
+GET /{owner}/{repo}/actions.atom
11
+```
12
+
13
+Returns the last 50 workflow runs as `application/atom+xml`. Visibility
14
+matches the Actions tab: public repositories are public; private
15
+repositories require repository read access.
16
+
17
+Each entry links to the run page and summarizes workflow name, event,
18
+branch, commit, status, and conclusion.
19
+
20
+## Cancel job
21
+
22
+```text
23
+POST /api/v1/jobs/{id}/cancel
24
+```
25
+
26
+Requests cancellation for a workflow job. Queued jobs become terminal
27
+immediately. Running jobs set `cancel_requested=true`; the runner observes
28
+that flag through its cancel-check endpoint, stops the active container, and
29
+reports the terminal status.
30
+
31
+Response: `202 Accepted`.
32
+
33
+```json
34
+{
35
+  "job_id": 10,
36
+  "run_id": 4,
37
+  "repo_id": 2,
38
+  "changed_jobs": 1,
39
+  "run_completed": false,
40
+  "run_conclusion": ""
41
+}
42
+```
43
+
44
+## Re-run workflow run
45
+
46
+```text
47
+POST /api/v1/runs/{id}/rerun
48
+```
49
+
50
+Creates a new workflow run from the original run's commit and workflow file.
51
+Only terminal runs are rerunnable. The new run records `parent_run_id` so the
52
+history remains linked.
53
+
54
+Response: `201 Created`.
55
+
56
+```json
57
+{
58
+  "run_id": 12,
59
+  "run_index": 8,
60
+  "parent_run_id": 4,
61
+  "repo_id": 2,
62
+  "workflow_file": ".shithub/workflows/ci.yml",
63
+  "head_sha": "0123456789abcdef0123456789abcdef01234567"
64
+}
65
+```
docs/public/api/overview.mdmodified
95 lines changed — click to load
@@ -8,7 +8,8 @@ instead of PATs.
88
 > **Status.** The API is intentionally narrow today. Endpoints
99
 > currently shipped: `GET /api/v1/user`, the
1010
 > `/api/v1/repos/{owner}/{repo}/check-runs` family, and the
11
-> `/api/v1/user/starred*` stars endpoints. Other sections of this
11
+> `/api/v1/user/starred*` stars endpoints, plus the Actions lifecycle
12
+> routes in [Actions workflow API](actions.md). Other sections of this
1213
 > reference (Issues, Pull requests, Webhooks, etc.) describe the
1314
 > **planned** shape and will land in subsequent sprints. Pages
1415
 > that document planned-only endpoints carry a banner.
@@ -28,17 +29,30 @@ Authorization: Bearer shp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2829
 `Authorization: token shp_…` is also accepted as a synonym.
2930
 
3031
 A request with no `Authorization` header — or with an invalid /
31
-expired / revoked PAT — returns `401 Unauthorized` with:
32
+expired / revoked PAT — returns `401 Unauthorized` with the
33
+canonical JSON error envelope. The response also carries a
34
+`WWW-Authenticate: Bearer realm="shithub", error="invalid_token"`
35
+challenge so HTTP-aware clients can reauthenticate cleanly.
3236
 
3337
 ```json
34
-{"error": "unauthenticated"}
38
+{"error": "invalid token"}
3539
 ```
3640
 
3741
 A request whose PAT lacks the scope a route requires returns
38
-`403 Forbidden` with:
42
+`403 Forbidden` with the required scope surfaced in the
43
+`X-Accepted-OAuth-Scopes` response header. The token's actual
44
+scopes are echoed on every PAT-authenticated response (including
45
+errors) via `X-OAuth-Scopes`, so clients can build precise
46
+"missing scope X, present scopes Y, Z" error messages without
47
+parsing the body:
3948
 
40
-```json
41
-{"error": "insufficient scope"}
49
+```
50
+HTTP/1.1 403 Forbidden
51
+X-OAuth-Scopes: user:read
52
+X-Accepted-OAuth-Scopes: repo:write
53
+Content-Type: application/json; charset=utf-8
54
+
55
+{"error":"token lacks required scope: repo:write"}
4256
 ```
4357
 
4458
 ## Scopes
@@ -59,15 +73,47 @@ to a repo the user has no access to.
5973
   conventional HTTP status.
6074
 - **Cache-Control:** every API response sets `no-store`.
6175
 - **Pagination:** list endpoints accept `?per_page=` (default 30,
62
-  max 100) and return a `Link:` header with `next`, `prev`,
63
-  `first`, `last` URLs (RFC 5988). Cursor pagination on hot lists
64
-  uses `?cursor=…` and returns the next cursor in the `Link:`
76
+  max 100) and return an RFC 8288 `Link:` header with `next`,
77
+  `prev`, `first`, `last` URLs. URLs are absolute and use the
78
+  instance's configured public base URL. Forward-only feeds emit
79
+  only `next` / `prev` (no `first` / `last`) when totals are
80
+  expensive to compute. Cursor pagination on hot lists uses
81
+  `?cursor=…` and returns the next cursor in the same `Link:`
6582
   header.
6683
 - **Body cap:** request bodies are capped at 256 KiB. Larger
6784
   payloads return `413`.
6885
 - **Rate limits:** every response includes `X-RateLimit-Limit`,
6986
   `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers (Unix
70
-  timestamp). Exceeding the limit returns `429`.
87
+  timestamp). Exceeding the limit returns `429` with the canonical
88
+  error envelope, a `Retry-After` header (seconds), and
89
+  `X-RateLimit-Remaining: 0`. Defaults: 5000 / hour for PAT
90
+  callers (keyed by token id) and 60 / hour for anonymous callers
91
+  (keyed by remote IP). Operators tune via
92
+  `SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR` and
93
+  `SHITHUB_RATELIMIT__API__ANON_PER_HOUR`.
94
+- **Request id:** every response echoes `X-Request-Id`. If the
95
+  caller sends one (matching `[a-z0-9/:_-]{1,128}`) it's reused;
96
+  otherwise the server generates a fresh 16-byte hex id. Quote
97
+  this id in support reports — it correlates against the server
98
+  logs.
99
+
100
+## Capability discovery
101
+
102
+`GET /api/v1/meta` is unauthenticated and returns the server's
103
+version stamp plus a list of feature capability strings. Clients
104
+use it to gate optional features without trial-and-error:
105
+
106
+```json
107
+{
108
+  "version": "v0.2.0",
109
+  "commit": "abc1234",
110
+  "built_at": "2026-05-12T03:00:00Z",
111
+  "capabilities": ["pat-auth", "check-runs", "stars", "actions-lifecycle"]
112
+}
113
+```
114
+
115
+New capability identifiers are appended as feature batches land;
116
+existing entries are stable.
71117
 
72118
 ## Versioning
73119
 
docs/public/api/webhooks.mdmodified
20 lines changed — click to load
@@ -45,6 +45,9 @@ The events shippable today, by `X-Shithub-Event` header:
4545
 - `check_run` (actions: `created`, `completed`, `rerequested`)
4646
 - `check_suite` (actions: `requested`, `completed`,
4747
   `rerequested`)
48
+- `workflow_run` (actions: `queued`, `running`, `completed`)
49
+- `workflow_job` (actions: `queued`, `running`, `completed`,
50
+  `cancelled`)
4851
 - `star`
4952
 - `fork`
5053
 - `repository` (actions: `created`, `deleted`, `archived`,
@@ -55,3 +58,11 @@ The events shippable today, by `X-Shithub-Event` header:
5558
 Each event's payload is documented per-type in the webhook detail
5659
 page's "Recent deliveries" inspector — that's currently the
5760
 authoritative reference until per-event documentation lands here.
61
+
62
+### Actions payload safety
63
+
64
+`workflow_run` and `workflow_job` payloads are structural snapshots:
65
+ids, run index, workflow path/name, head SHA/ref, event kind, status,
66
+conclusion, timestamps, job key/name, runner id, needs, timeout, and
67
+cancellation state. They intentionally do **not** include workflow
68
+event payloads, env, permissions, logs, runner JWTs, or secret values.
docs/public/user/actions.mdadded
92 lines changed — click to load
@@ -0,0 +1,92 @@
1
+# Actions
2
+
3
+shithub Actions runs CI workflows from `.shithub/workflows/*.yml`.
4
+The workflow format intentionally follows the parts of GitHub Actions that are
5
+useful for ordinary repository CI, while keeping the runner surface small enough
6
+to secure.
7
+
8
+## Minimal workflow
9
+
10
+```yaml
11
+name: smoke
12
+on: [push, workflow_dispatch]
13
+jobs:
14
+  hello:
15
+    runs-on: ubuntu-latest
16
+    env:
17
+      RUN_ID: ${{ shithub.run_id }}
18
+    steps:
19
+      - run: echo "hello from shithub actions"
20
+      - run: test -n "$RUN_ID"
21
+```
22
+
23
+Commit that file as `.shithub/workflows/smoke.yml` and push to the repository.
24
+The run appears under the repository's Actions tab and its job also appears as
25
+a check run on matching pull requests.
26
+
27
+## What works today
28
+
29
+- `push`, `pull_request`, `schedule`, and `workflow_dispatch` triggers
30
+- `run:` steps executed in the operator-configured runner image
31
+- `runs-on:` label matching against registered runners
32
+- workflow, job, and step `env:`
33
+- `${{ secrets.NAME }}`, `${{ vars.NAME }}`, `${{ env.NAME }}`, and
34
+  `${{ shithub.* }}` expressions
35
+- `needs:`, `if:`, `timeout-minutes:`, and concurrency groups
36
+- live step logs, cancel, re-run, check-run sync, and the Actions Atom feed
37
+
38
+`runs-on: ubuntu-latest` is a runner label, not a promise that shithub downloads
39
+a hosted Ubuntu image for you. The site operator decides which image a matching
40
+runner uses. On shithub.sh, use the labels published by the instance operator.
41
+
42
+## Current limit
43
+
44
+Use `run:` steps for now. The parser accepts these reserved aliases:
45
+
46
+- `actions/checkout@v4`
47
+- `shithub/upload-artifact@v1`
48
+- `shithub/download-artifact@v1`
49
+
50
+The runner does not execute them yet. A workflow containing those `uses:` steps
51
+will fail until checkout and artifact execution land. If you need repository
52
+files in a smoke workflow today, keep the command self-contained or fetch what
53
+you need explicitly inside a `run:` step.
54
+
55
+## Expressions
56
+
57
+Use the shithub namespace:
58
+
59
+```yaml
60
+env:
61
+  REF: ${{ shithub.ref }}
62
+  SHA: ${{ shithub.sha }}
63
+  RUN_ID: ${{ shithub.run_id }}
64
+```
65
+
66
+The `github.*` namespace is accepted as a compatibility alias for the fields
67
+shithub exposes, but new workflows should use `shithub.*`.
68
+
69
+Event payload values such as `${{ shithub.event.pull_request.title }}` are
70
+treated as untrusted. The runner passes them through temporary environment
71
+bindings instead of splicing them directly into shell command text.
72
+
73
+## Secrets and variables
74
+
75
+Repository and organization settings expose Actions secrets and variables.
76
+Secrets are encrypted at rest and are redacted from logs. Variables are
77
+plaintext configuration and are suitable for non-secret values such as tool
78
+versions or feature flags.
79
+
80
+Repo-scoped values shadow organization-scoped values with the same name.
81
+
82
+## Migrating from GitHub Actions
83
+
84
+Most simple CI files need three edits:
85
+
86
+1. Move the workflow file from `.github/workflows/` to `.shithub/workflows/`.
87
+2. Replace `uses:` actions with equivalent `run:` commands.
88
+3. Confirm `runs-on:` matches a label registered by your shithub operator.
89
+
90
+Marketplace actions, Docker actions, composite actions, hosted runner images,
91
+matrix expansion, service containers, and built-in checkout are not part of the
92
+current v1 runner.
docs/public/user/webhooks.mdmodified
27 lines changed — click to load
@@ -21,7 +21,8 @@ Repository → Settings → Webhooks → "Add webhook".
2121
 
2222
 Each delivery includes:
2323
 
24
-- `X-Shithub-Event: <event-name>` — e.g., `push`, `pull_request`.
24
+- `X-Shithub-Event: <event-name>` — e.g., `push`, `pull_request`,
25
+  `workflow_run`.
2526
 - `X-Shithub-Delivery: <uuid>` — unique per delivery (idempotent).
2627
 - `X-Shithub-Signature-256: sha256=<hex>` — HMAC-SHA256 of the
2728
   raw body using your configured secret.
@@ -105,6 +106,18 @@ Webhook detail page → "Recent deliveries". Each row shows:
105106
 Stored bodies are capped at 32 KiB (your endpoint can accept
106107
 bigger; we just don't keep more for the inspector).
107108
 
109
+## Actions events
110
+
111
+Repository webhooks can subscribe to Actions lifecycle events:
112
+
113
+- `workflow_run` actions: `queued`, `running`, `completed`.
114
+- `workflow_job` actions: `queued`, `running`, `completed`,
115
+  `cancelled`.
116
+
117
+Actions payloads only carry structural run/job metadata. shithub does
118
+not include workflow event payloads, env, permissions, logs, runner
119
+tokens, or secrets in webhook bodies.
120
+
108121
 ## SSRF defense
109122
 
110123
 shithub validates webhook URLs server-side: hostnames are
internal/actions/events/emit.goadded
143 lines changed — click to load
@@ -0,0 +1,143 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package events emits webhook-facing Actions lifecycle events.
4
+//
5
+// These payloads are deliberately structural. Do not include workflow
6
+// event_payload, env, permissions, step logs, runner JWTs, or secret material.
7
+package events
8
+
9
+import (
10
+	"context"
11
+
12
+	"github.com/jackc/pgx/v5"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/notif"
17
+)
18
+
19
+const (
20
+	KindWorkflowRun = "workflow_run"
21
+	KindWorkflowJob = "workflow_job"
22
+
23
+	ActionQueued    = "queued"
24
+	ActionRunning   = "running"
25
+	ActionCompleted = "completed"
26
+	ActionCancelled = "cancelled"
27
+)
28
+
29
+// EmitRunTx writes one workflow_run domain event inside the caller's
30
+// transaction. Use it when the run mutation and event row must commit
31
+// atomically.
32
+func EmitRunTx(ctx context.Context, tx pgx.Tx, run actionsdb.WorkflowRun, action string) error {
33
+	return notif.EmitTx(ctx, tx, runEvent(run, action))
34
+}
35
+
36
+// EmitJobTx writes one workflow_job domain event inside the caller's
37
+// transaction. The parent run snapshot is included so webhook subscribers do
38
+// not need a second API call to identify the workflow execution.
39
+func EmitJobTx(ctx context.Context, tx pgx.Tx, run actionsdb.WorkflowRun, job actionsdb.WorkflowJob, action string) error {
40
+	return notif.EmitTx(ctx, tx, jobEvent(run, job, action))
41
+}
42
+
43
+func runEvent(run actionsdb.WorkflowRun, action string) notif.Event {
44
+	return notif.Event{
45
+		ActorUserID: int8Value(run.ActorUserID),
46
+		Kind:        KindWorkflowRun,
47
+		RepoID:      run.RepoID,
48
+		SourceKind:  KindWorkflowRun,
49
+		SourceID:    run.ID,
50
+		Public:      false,
51
+		Extra: map[string]any{
52
+			"action":       action,
53
+			"workflow_run": runPayload(run),
54
+		},
55
+	}
56
+}
57
+
58
+func jobEvent(run actionsdb.WorkflowRun, job actionsdb.WorkflowJob, action string) notif.Event {
59
+	return notif.Event{
60
+		ActorUserID: int8Value(run.ActorUserID),
61
+		Kind:        KindWorkflowJob,
62
+		RepoID:      run.RepoID,
63
+		SourceKind:  KindWorkflowJob,
64
+		SourceID:    job.ID,
65
+		Public:      false,
66
+		Extra: map[string]any{
67
+			"action":       action,
68
+			"workflow_run": runPayload(run),
69
+			"workflow_job": jobPayload(job),
70
+		},
71
+	}
72
+}
73
+
74
+func runPayload(run actionsdb.WorkflowRun) map[string]any {
75
+	return map[string]any{
76
+		"id":               run.ID,
77
+		"repo_id":          run.RepoID,
78
+		"run_index":        run.RunIndex,
79
+		"workflow_file":    run.WorkflowFile,
80
+		"workflow_name":    run.WorkflowName,
81
+		"head_sha":         run.HeadSha,
82
+		"head_ref":         run.HeadRef,
83
+		"event":            string(run.Event),
84
+		"status":           string(run.Status),
85
+		"conclusion":       conclusionValue(run.Conclusion),
86
+		"actor_user_id":    int8Nullable(run.ActorUserID),
87
+		"parent_run_id":    int8Nullable(run.ParentRunID),
88
+		"created_at":       timeValue(run.CreatedAt),
89
+		"updated_at":       timeValue(run.UpdatedAt),
90
+		"started_at":       timeValue(run.StartedAt),
91
+		"completed_at":     timeValue(run.CompletedAt),
92
+		"trigger_event_id": run.TriggerEventID,
93
+	}
94
+}
95
+
96
+func jobPayload(job actionsdb.WorkflowJob) map[string]any {
97
+	return map[string]any{
98
+		"id":               job.ID,
99
+		"run_id":           job.RunID,
100
+		"job_index":        job.JobIndex,
101
+		"job_key":          job.JobKey,
102
+		"job_name":         job.JobName,
103
+		"runs_on":          job.RunsOn,
104
+		"runner_id":        int8Nullable(job.RunnerID),
105
+		"needs_jobs":       job.NeedsJobs,
106
+		"timeout_minutes":  job.TimeoutMinutes,
107
+		"status":           string(job.Status),
108
+		"conclusion":       conclusionValue(job.Conclusion),
109
+		"cancel_requested": job.CancelRequested,
110
+		"created_at":       timeValue(job.CreatedAt),
111
+		"updated_at":       timeValue(job.UpdatedAt),
112
+		"started_at":       timeValue(job.StartedAt),
113
+		"completed_at":     timeValue(job.CompletedAt),
114
+	}
115
+}
116
+
117
+func conclusionValue(v actionsdb.NullCheckConclusion) any {
118
+	if !v.Valid {
119
+		return nil
120
+	}
121
+	return string(v.CheckConclusion)
122
+}
123
+
124
+func int8Value(v pgtype.Int8) int64 {
125
+	if !v.Valid {
126
+		return 0
127
+	}
128
+	return v.Int64
129
+}
130
+
131
+func int8Nullable(v pgtype.Int8) any {
132
+	if !v.Valid {
133
+		return nil
134
+	}
135
+	return v.Int64
136
+}
137
+
138
+func timeValue(v pgtype.Timestamptz) any {
139
+	if !v.Valid {
140
+		return nil
141
+	}
142
+	return v.Time.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")
143
+}
internal/actions/events/emit_test.goadded
83 lines changed — click to load
@@ -0,0 +1,83 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package events
4
+
5
+import (
6
+	"encoding/json"
7
+	"strings"
8
+	"testing"
9
+	"time"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
14
+)
15
+
16
+func TestPayloadsExcludeSensitiveWorkflowState(t *testing.T) {
17
+	now := pgtype.Timestamptz{Time: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC), Valid: true}
18
+	run := actionsdb.WorkflowRun{
19
+		ID:             11,
20
+		RepoID:         22,
21
+		RunIndex:       3,
22
+		WorkflowFile:   ".shithub/workflows/ci.yml",
23
+		WorkflowName:   "CI",
24
+		HeadSha:        strings.Repeat("a", 40),
25
+		HeadRef:        "refs/heads/trunk",
26
+		Event:          actionsdb.WorkflowRunEventPush,
27
+		EventPayload:   []byte(`{"secret":"do-not-emit"}`),
28
+		ActorUserID:    pgtype.Int8{Int64: 33, Valid: true},
29
+		Status:         actionsdb.WorkflowRunStatusRunning,
30
+		TriggerEventID: "push:demo",
31
+		CreatedAt:      now,
32
+		UpdatedAt:      now,
33
+		StartedAt:      now,
34
+	}
35
+	job := actionsdb.WorkflowJob{
36
+		ID:             44,
37
+		RunID:          run.ID,
38
+		JobIndex:       0,
39
+		JobKey:         "build",
40
+		JobName:        "Build",
41
+		RunsOn:         "ubuntu-latest",
42
+		NeedsJobs:      []string{},
43
+		TimeoutMinutes: 30,
44
+		Permissions:    []byte(`{"contents":"read"}`),
45
+		JobEnv:         []byte(`{"TOKEN":"do-not-emit"}`),
46
+		Status:         actionsdb.WorkflowJobStatusCompleted,
47
+		Conclusion:     actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusionSuccess, Valid: true},
48
+		CreatedAt:      now,
49
+		UpdatedAt:      now,
50
+		StartedAt:      now,
51
+		CompletedAt:    now,
52
+	}
53
+
54
+	payload, err := json.Marshal(jobEvent(run, job, ActionCompleted).Extra)
55
+	if err != nil {
56
+		t.Fatalf("marshal payload: %v", err)
57
+	}
58
+	got := string(payload)
59
+	for _, forbidden := range []string{
60
+		"event_payload",
61
+		"do-not-emit",
62
+		"permissions",
63
+		"job_env",
64
+		"TOKEN",
65
+		"secret",
66
+	} {
67
+		if strings.Contains(got, forbidden) {
68
+			t.Fatalf("payload leaked %q: %s", forbidden, got)
69
+		}
70
+	}
71
+	for _, required := range []string{
72
+		`"action":"completed"`,
73
+		`"workflow_run"`,
74
+		`"workflow_job"`,
75
+		`"status":"completed"`,
76
+		`"conclusion":"success"`,
77
+		`"trigger_event_id":"push:demo"`,
78
+	} {
79
+		if !strings.Contains(got, required) {
80
+			t.Fatalf("payload missing %q: %s", required, got)
81
+		}
82
+	}
83
+}
internal/actions/lifecycle/cancel.gomodified
60 lines changed — click to load
@@ -12,6 +12,7 @@ import (
1212
 	"github.com/jackc/pgx/v5"
1313
 
1414
 	"github.com/tenseleyFlow/shithub/internal/actions/checksync"
15
+	actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events"
1516
 	"github.com/tenseleyFlow/shithub/internal/actions/runstate"
1617
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
1718
 	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
@@ -73,6 +74,13 @@ func CancelRun(ctx context.Context, deps Deps, runID int64, reason string) (Canc
7374
 		if err != nil {
7475
 			return CancelResult{}, err
7576
 		}
77
+		run, err := q.GetWorkflowRunByID(ctx, tx, runID)
78
+		if err != nil {
79
+			return CancelResult{}, err
80
+		}
81
+		if err := emitCancelEvents(ctx, tx, run, changed, runCompleted); err != nil {
82
+			return CancelResult{}, err
83
+		}
7684
 	}
7785
 	if err := tx.Commit(ctx); err != nil {
7886
 		return CancelResult{}, err
@@ -138,6 +146,13 @@ func CancelJob(ctx context.Context, deps Deps, jobID int64, reason string) (Canc
138146
 		if err != nil {
139147
 			return CancelResult{}, err
140148
 		}
149
+		run, err := q.GetWorkflowRunByID(ctx, tx, runID)
150
+		if err != nil {
151
+			return CancelResult{}, err
152
+		}
153
+		if err := emitCancelEvents(ctx, tx, run, changed, runCompleted); err != nil {
154
+			return CancelResult{}, err
155
+		}
141156
 	}
142157
 	if err := tx.Commit(ctx); err != nil {
143158
 		return CancelResult{}, err
@@ -161,6 +176,27 @@ func recordCancelledJobs(jobs []actionsdb.WorkflowJob, reason string) {
161176
 	metrics.ActionsJobsCancelledTotal.WithLabelValues(cancelReason(reason)).Add(float64(len(jobs)))
162177
 }
163178
 
179
+func emitCancelEvents(ctx context.Context, tx pgx.Tx, run actionsdb.WorkflowRun, jobs []actionsdb.WorkflowJob, runCompleted bool) error {
180
+	for _, job := range jobs {
181
+		if job.Status != actionsdb.WorkflowJobStatusCancelled {
182
+			continue
183
+		}
184
+		if err := actionsevents.EmitJobTx(ctx, tx, run, job, actionsevents.ActionCancelled); err != nil {
185
+			return err
186
+		}
187
+	}
188
+	if runCompleted {
189
+		action := actionsevents.ActionCompleted
190
+		if run.Status == actionsdb.WorkflowRunStatusCancelled {
191
+			action = actionsevents.ActionCancelled
192
+		}
193
+		if err := actionsevents.EmitRunTx(ctx, tx, run, action); err != nil {
194
+			return err
195
+		}
196
+	}
197
+	return nil
198
+}
199
+
164200
 func cancelReason(reason string) string {
165201
 	switch strings.TrimSpace(reason) {
166202
 	case CancelReasonUser:
internal/actions/lifecycle/cancel_test.gomodified
95 lines changed — click to load
@@ -146,12 +146,67 @@ func TestCancelJobRequestsRunningJobWithoutTerminalOverwrite(t *testing.T) {
146146
 	}
147147
 }
148148
 
149
+func TestListActiveWorkflowRunsForAdminFiltersActiveRuns(t *testing.T) {
150
+	ctx := context.Background()
151
+	pool := dbtest.NewTestDB(t)
152
+	repoID, userID := setupLifecycleRepo(t, pool)
153
+	q := actionsdb.New()
154
+
155
+	queued := insertLifecycleRun(t, pool, repoID, userID, 1)
156
+	running := insertLifecycleRun(t, pool, repoID, userID, 2)
157
+	running, err := q.StartWorkflowRun(ctx, pool, running.ID)
158
+	if err != nil {
159
+		t.Fatalf("StartWorkflowRun: %v", err)
160
+	}
161
+	completed := insertLifecycleRun(t, pool, repoID, userID, 3)
162
+	if _, err := q.CompleteWorkflowRun(ctx, pool, actionsdb.CompleteWorkflowRunParams{
163
+		ID:         completed.ID,
164
+		Conclusion: actionsdb.CheckConclusionSuccess,
165
+	}); err != nil {
166
+		t.Fatalf("CompleteWorkflowRun: %v", err)
167
+	}
168
+	otherRepoID, otherUserID := setupNamedLifecycleRepo(t, pool, "bob", "other")
169
+	otherRepoRun := insertLifecycleRun(t, pool, otherRepoID, otherUserID, 1)
170
+
171
+	all, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{
172
+		RepoID:     0,
173
+		LimitCount: 10,
174
+	})
175
+	if err != nil {
176
+		t.Fatalf("ListActiveWorkflowRunsForAdmin all: %v", err)
177
+	}
178
+	assertRunIDs(t, all, queued.ID, running.ID, otherRepoRun.ID)
179
+
180
+	repoOnly, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{
181
+		RepoID:     repoID,
182
+		LimitCount: 10,
183
+	})
184
+	if err != nil {
185
+		t.Fatalf("ListActiveWorkflowRunsForAdmin repo: %v", err)
186
+	}
187
+	assertRunIDs(t, repoOnly, queued.ID, running.ID)
188
+
189
+	limited, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{
190
+		RepoID:     0,
191
+		LimitCount: 1,
192
+	})
193
+	if err != nil {
194
+		t.Fatalf("ListActiveWorkflowRunsForAdmin limited: %v", err)
195
+	}
196
+	assertRunIDs(t, limited, queued.ID)
197
+}
198
+
149199
 func setupLifecycleRepo(t *testing.T, db actionsdb.DBTX) (repoID, userID int64) {
200
+	t.Helper()
201
+	return setupNamedLifecycleRepo(t, db, "alice", "demo")
202
+}
203
+
204
+func setupNamedLifecycleRepo(t *testing.T, db actionsdb.DBTX, username, repoName string) (repoID, userID int64) {
150205
 	t.Helper()
151206
 	ctx := context.Background()
152207
 	user, err := usersdb.New().CreateUser(ctx, db, usersdb.CreateUserParams{
153
-		Username:     "alice",
154
-		DisplayName:  "Alice",
208
+		Username:     username,
209
+		DisplayName:  username,
155210
 		PasswordHash: fixtureHash,
156211
 	})
157212
 	if err != nil {
@@ -159,7 +214,7 @@ func setupLifecycleRepo(t *testing.T, db actionsdb.DBTX) (repoID, userID int64)
159214
 	}
160215
 	repo, err := reposdb.New().CreateRepo(ctx, db, reposdb.CreateRepoParams{
161216
 		OwnerUserID:   pgtype.Int8{Int64: user.ID, Valid: true},
162
-		Name:          "demo",
217
+		Name:          repoName,
163218
 		DefaultBranch: "trunk",
164219
 		Visibility:    reposdb.RepoVisibilityPublic,
165220
 	})
@@ -169,6 +224,18 @@ func setupLifecycleRepo(t *testing.T, db actionsdb.DBTX) (repoID, userID int64)
169224
 	return repo.ID, user.ID
170225
 }
171226
 
227
+func assertRunIDs(t *testing.T, runs []actionsdb.WorkflowRun, want ...int64) {
228
+	t.Helper()
229
+	if len(runs) != len(want) {
230
+		t.Fatalf("got %d runs, want %d: %+v", len(runs), len(want), runs)
231
+	}
232
+	for i := range want {
233
+		if runs[i].ID != want[i] {
234
+			t.Fatalf("run[%d] id=%d, want %d; runs=%+v", i, runs[i].ID, want[i], runs)
235
+		}
236
+	}
237
+}
238
+
172239
 func insertLifecycleRun(t *testing.T, db actionsdb.DBTX, repoID, userID, runIndex int64) actionsdb.WorkflowRun {
173240
 	t.Helper()
174241
 	run, err := actionsdb.New().InsertWorkflowRun(context.Background(), db, actionsdb.InsertWorkflowRunParams{
internal/actions/logstream/logstream.gomodified
11 lines changed — click to load
@@ -33,6 +33,11 @@ func ListenSQL(stepID int64) string {
3333
 	return "LISTEN " + pgx.Identifier{Channel(stepID)}.Sanitize()
3434
 }
3535
 
36
+// UnlistenSQL returns a quoted UNLISTEN statement for the per-step channel.
37
+func UnlistenSQL(stepID int64) string {
38
+	return "UNLISTEN " + pgx.Identifier{Channel(stepID)}.Sanitize()
39
+}
40
+
3641
 // NotifyChunk wakes log tailers for a newly-persisted chunk.
3742
 func NotifyChunk(ctx context.Context, db DBTX, stepID int64, seq int32) error {
3843
 	return notify(ctx, db, stepID, strconv.FormatInt(int64(seq), 10))
internal/actions/logstream/logstream_test.gomodified
9 lines changed — click to load
@@ -12,6 +12,9 @@ func TestChannelAndListenSQL(t *testing.T) {
1212
 	if got := ListenSQL(42); got != `LISTEN "step_log_42"` {
1313
 		t.Fatalf("ListenSQL=%q", got)
1414
 	}
15
+	if got := UnlistenSQL(42); got != `UNLISTEN "step_log_42"` {
16
+		t.Fatalf("UnlistenSQL=%q", got)
17
+	}
1518
 }
1619
 
1720
 func TestParsePayload(t *testing.T) {
internal/actions/queries/workflow_runs.sqlmodified
37 lines changed — click to load
@@ -109,6 +109,19 @@ SET status = 'running',
109109
     updated_at = now()
110110
 WHERE id = $1 AND status = 'queued';
111111
 
112
+-- name: StartWorkflowRun :one
113
+UPDATE workflow_runs
114
+SET status = 'running',
115
+    started_at = COALESCE(started_at, now()),
116
+    version = version + 1,
117
+    updated_at = now()
118
+WHERE id = $1 AND status = 'queued'
119
+RETURNING id, repo_id, run_index, workflow_file, workflow_name,
120
+          head_sha, head_ref, event, event_payload,
121
+          actor_user_id, parent_run_id, concurrency_group,
122
+          status, conclusion, pinned, need_approval, approved_by_user_id,
123
+          started_at, completed_at, version, created_at, updated_at, trigger_event_id;
124
+
112125
 -- name: CompleteWorkflowRun :one
113126
 UPDATE workflow_runs
114127
 SET status = 'completed',
@@ -163,6 +176,18 @@ WHERE r.repo_id = sqlc.arg(repo_id)::bigint
163176
   AND (sqlc.narg(conclusion)::check_conclusion IS NULL OR r.conclusion = sqlc.narg(conclusion)::check_conclusion)
164177
   AND (sqlc.narg(actor_username)::text IS NULL OR u.username = sqlc.narg(actor_username)::citext);
165178
 
179
+-- name: ListActiveWorkflowRunsForAdmin :many
180
+SELECT id, repo_id, run_index, workflow_file, workflow_name,
181
+       head_sha, head_ref, event, event_payload,
182
+       actor_user_id, parent_run_id, concurrency_group,
183
+       status, conclusion, pinned, need_approval, approved_by_user_id,
184
+       started_at, completed_at, version, created_at, updated_at, trigger_event_id
185
+FROM workflow_runs
186
+WHERE status IN ('queued', 'running')
187
+  AND (sqlc.arg(repo_id)::bigint = 0 OR repo_id = sqlc.arg(repo_id)::bigint)
188
+ORDER BY created_at ASC, id ASC
189
+LIMIT sqlc.arg(limit_count)::int;
190
+
166191
 -- name: ListWorkflowRunWorkflowsForRepo :many
167192
 WITH ranked AS (
168193
     SELECT workflow_file,
internal/actions/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/actions/sqlc/querier.gomodified
14 lines changed — click to load
@@ -65,6 +65,7 @@ type Querier interface {
6565
 	InsertWorkflowRun(ctx context.Context, db DBTX, arg InsertWorkflowRunParams) (WorkflowRun, error)
6666
 	// SPDX-License-Identifier: AGPL-3.0-or-later
6767
 	InsertWorkflowStep(ctx context.Context, db DBTX, arg InsertWorkflowStepParams) (WorkflowStep, error)
68
+	ListActiveWorkflowRunsForAdmin(ctx context.Context, db DBTX, arg ListActiveWorkflowRunsForAdminParams) ([]WorkflowRun, error)
6869
 	ListAllStepLogChunksForStep(ctx context.Context, db DBTX, stepID int64) ([]WorkflowStepLogChunk, error)
6970
 	ListArtifactsForRun(ctx context.Context, db DBTX, runID int64) ([]ListArtifactsForRunRow, error)
7071
 	// Older queued/running runs with the same group block the new run while they
@@ -102,6 +103,7 @@ type Querier interface {
102103
 	RequestWorkflowJobCancel(ctx context.Context, db DBTX, id int64) (WorkflowJob, error)
103104
 	RequestWorkflowRunCancel(ctx context.Context, db DBTX, runID int64) ([]WorkflowJob, error)
104105
 	RevokeAllTokensForRunner(ctx context.Context, db DBTX, runnerID int64) error
106
+	StartWorkflowRun(ctx context.Context, db DBTX, id int64) (WorkflowRun, error)
105107
 	TouchRunnerHeartbeat(ctx context.Context, db DBTX, arg TouchRunnerHeartbeatParams) error
106108
 	UpdateStepLogChunk(ctx context.Context, db DBTX, arg UpdateStepLogChunkParams) error
107109
 	UpdateWorkflowJobStatus(ctx context.Context, db DBTX, arg UpdateWorkflowJobStatusParams) (WorkflowJob, error)
internal/actions/sqlc/workflow_runs.sql.gomodified
116 lines changed — click to load
@@ -398,6 +398,68 @@ func (q *Queries) InsertWorkflowRun(ctx context.Context, db DBTX, arg InsertWork
398398
 	return i, err
399399
 }
400400
 
401
+const listActiveWorkflowRunsForAdmin = `-- name: ListActiveWorkflowRunsForAdmin :many
402
+SELECT id, repo_id, run_index, workflow_file, workflow_name,
403
+       head_sha, head_ref, event, event_payload,
404
+       actor_user_id, parent_run_id, concurrency_group,
405
+       status, conclusion, pinned, need_approval, approved_by_user_id,
406
+       started_at, completed_at, version, created_at, updated_at, trigger_event_id
407
+FROM workflow_runs
408
+WHERE status IN ('queued', 'running')
409
+  AND ($1::bigint = 0 OR repo_id = $1::bigint)
410
+ORDER BY created_at ASC, id ASC
411
+LIMIT $2::int
412
+`
413
+
414
+type ListActiveWorkflowRunsForAdminParams struct {
415
+	RepoID     int64
416
+	LimitCount int32
417
+}
418
+
419
+func (q *Queries) ListActiveWorkflowRunsForAdmin(ctx context.Context, db DBTX, arg ListActiveWorkflowRunsForAdminParams) ([]WorkflowRun, error) {
420
+	rows, err := db.Query(ctx, listActiveWorkflowRunsForAdmin, arg.RepoID, arg.LimitCount)
421
+	if err != nil {
422
+		return nil, err
423
+	}
424
+	defer rows.Close()
425
+	items := []WorkflowRun{}
426
+	for rows.Next() {
427
+		var i WorkflowRun
428
+		if err := rows.Scan(
429
+			&i.ID,
430
+			&i.RepoID,
431
+			&i.RunIndex,
432
+			&i.WorkflowFile,
433
+			&i.WorkflowName,
434
+			&i.HeadSha,
435
+			&i.HeadRef,
436
+			&i.Event,
437
+			&i.EventPayload,
438
+			&i.ActorUserID,
439
+			&i.ParentRunID,
440
+			&i.ConcurrencyGroup,
441
+			&i.Status,
442
+			&i.Conclusion,
443
+			&i.Pinned,
444
+			&i.NeedApproval,
445
+			&i.ApprovedByUserID,
446
+			&i.StartedAt,
447
+			&i.CompletedAt,
448
+			&i.Version,
449
+			&i.CreatedAt,
450
+			&i.UpdatedAt,
451
+			&i.TriggerEventID,
452
+		); err != nil {
453
+			return nil, err
454
+		}
455
+		items = append(items, i)
456
+	}
457
+	if err := rows.Err(); err != nil {
458
+		return nil, err
459
+	}
460
+	return items, nil
461
+}
462
+
401463
 const listBlockingConcurrencyRunsForUpdate = `-- name: ListBlockingConcurrencyRunsForUpdate :many
402464
 SELECT r.id, r.repo_id, r.run_index, r.workflow_file, r.workflow_name,
403465
        r.head_sha, r.head_ref, r.event, r.event_payload,
@@ -698,3 +760,48 @@ func (q *Queries) NextRunIndexForRepo(ctx context.Context, db DBTX, repoID int64
698760
 	err := row.Scan(&next_index)
699761
 	return next_index, err
700762
 }
763
+
764
+const startWorkflowRun = `-- name: StartWorkflowRun :one
765
+UPDATE workflow_runs
766
+SET status = 'running',
767
+    started_at = COALESCE(started_at, now()),
768
+    version = version + 1,
769
+    updated_at = now()
770
+WHERE id = $1 AND status = 'queued'
771
+RETURNING id, repo_id, run_index, workflow_file, workflow_name,
772
+          head_sha, head_ref, event, event_payload,
773
+          actor_user_id, parent_run_id, concurrency_group,
774
+          status, conclusion, pinned, need_approval, approved_by_user_id,
775
+          started_at, completed_at, version, created_at, updated_at, trigger_event_id
776
+`
777
+
778
+func (q *Queries) StartWorkflowRun(ctx context.Context, db DBTX, id int64) (WorkflowRun, error) {
779
+	row := db.QueryRow(ctx, startWorkflowRun, id)
780
+	var i WorkflowRun
781
+	err := row.Scan(
782
+		&i.ID,
783
+		&i.RepoID,
784
+		&i.RunIndex,
785
+		&i.WorkflowFile,
786
+		&i.WorkflowName,
787
+		&i.HeadSha,
788
+		&i.HeadRef,
789
+		&i.Event,
790
+		&i.EventPayload,
791
+		&i.ActorUserID,
792
+		&i.ParentRunID,
793
+		&i.ConcurrencyGroup,
794
+		&i.Status,
795
+		&i.Conclusion,
796
+		&i.Pinned,
797
+		&i.NeedApproval,
798
+		&i.ApprovedByUserID,
799
+		&i.StartedAt,
800
+		&i.CompletedAt,
801
+		&i.Version,
802
+		&i.CreatedAt,
803
+		&i.UpdatedAt,
804
+		&i.TriggerEventID,
805
+	)
806
+	return i, err
807
+}
internal/actions/trigger/enqueue.gomodified
84 lines changed — click to load
@@ -15,6 +15,7 @@ import (
1515
 
1616
 	"github.com/tenseleyFlow/shithub/internal/actions/checksync"
1717
 	"github.com/tenseleyFlow/shithub/internal/actions/concurrency"
18
+	actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events"
1819
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
1920
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
2021
 	"github.com/tenseleyFlow/shithub/internal/checks"
@@ -184,6 +185,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) {
184185
 		}
185186
 		return Result{}, fmt.Errorf("trigger: insert run: %w", err)
186187
 	}
188
+	if err := actionsevents.EmitRunTx(ctx, tx, run, actionsevents.ActionQueued); err != nil {
189
+		return Result{}, fmt.Errorf("trigger: emit run queued event: %w", err)
190
+	}
187191
 
188192
 	// Persist child jobs + their steps. Order in Workflow.Jobs is YAML
189193
 	// document order, which we preserve via job_index.
@@ -218,6 +222,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) {
218222
 			return Result{}, fmt.Errorf("trigger: insert job %s: %w", j.Key, err)
219223
 		}
220224
 		jobIDs[i] = job.ID
225
+		if err := actionsevents.EmitJobTx(ctx, tx, run, job, actionsevents.ActionQueued); err != nil {
226
+			return Result{}, fmt.Errorf("trigger: emit job queued event for %s: %w", j.Key, err)
227
+		}
221228
 
222229
 		for si, s := range j.Steps {
223230
 			stepEnvJSON, err := marshalEnv(s.Env)
@@ -253,6 +260,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) {
253260
 	if err != nil {
254261
 		return Result{}, fmt.Errorf("trigger: enforce concurrency: %w", err)
255262
 	}
263
+	if err := emitConcurrencyCancelEvents(ctx, tx, q, concurrencyResult.CancelledJobs); err != nil {
264
+		return Result{}, err
265
+	}
256266
 
257267
 	if err := tx.Commit(ctx); err != nil {
258268
 		return Result{}, fmt.Errorf("trigger: commit run tx: %w", err)
@@ -348,6 +358,50 @@ func lookupExistingRun(ctx context.Context, pool *pgxpool.Pool, p EnqueueParams)
348358
 	return rows, nil
349359
 }
350360
 
361
+func emitConcurrencyCancelEvents(
362
+	ctx context.Context,
363
+	tx pgx.Tx,
364
+	q *actionsdb.Queries,
365
+	jobs []actionsdb.WorkflowJob,
366
+) error {
367
+	if len(jobs) == 0 {
368
+		return nil
369
+	}
370
+	emittedRun := map[int64]struct{}{}
371
+	for _, job := range jobs {
372
+		run, err := q.GetWorkflowRunByID(ctx, tx, job.RunID)
373
+		if err != nil {
374
+			return fmt.Errorf("trigger: load concurrency-cancelled run: %w", err)
375
+		}
376
+		if job.Status == actionsdb.WorkflowJobStatusCancelled {
377
+			if err := actionsevents.EmitJobTx(ctx, tx, run, job, actionsevents.ActionCancelled); err != nil {
378
+				return fmt.Errorf("trigger: emit concurrency job cancelled event: %w", err)
379
+			}
380
+		}
381
+		if _, ok := emittedRun[run.ID]; ok {
382
+			continue
383
+		}
384
+		if workflowRunTerminal(run.Status) {
385
+			if err := actionsevents.EmitRunTx(ctx, tx, run, runTerminalAction(run)); err != nil {
386
+				return fmt.Errorf("trigger: emit concurrency run terminal event: %w", err)
387
+			}
388
+			emittedRun[run.ID] = struct{}{}
389
+		}
390
+	}
391
+	return nil
392
+}
393
+
394
+func workflowRunTerminal(status actionsdb.WorkflowRunStatus) bool {
395
+	return status == actionsdb.WorkflowRunStatusCompleted || status == actionsdb.WorkflowRunStatusCancelled
396
+}
397
+
398
+func runTerminalAction(run actionsdb.WorkflowRun) string {
399
+	if run.Status == actionsdb.WorkflowRunStatusCancelled {
400
+		return actionsevents.ActionCancelled
401
+	}
402
+	return actionsevents.ActionCompleted
403
+}
404
+
351405
 func pgInt8(v int64) pgtype.Int8 {
352406
 	return pgtype.Int8{Int64: v, Valid: v != 0}
353407
 }
internal/actions/trigger/enqueue_test.gomodified
36 lines changed — click to load
@@ -143,6 +143,10 @@ func TestEnqueue_HappyPath(t *testing.T) {
143143
 	if run.Status != actionsdb.WorkflowRunStatusQueued {
144144
 		t.Errorf("status: got %s want queued", run.Status)
145145
 	}
146
+	assertDomainEventCounts(t, f.pool, f.repoID, map[string]int64{
147
+		"workflow_run": 1,
148
+		"workflow_job": 1,
149
+	})
146150
 }
147151
 
148152
 func TestEnqueue_ResolvesConcurrencyGroupExpression(t *testing.T) {
@@ -235,6 +239,26 @@ func TestEnqueue_CancelInProgressCancelsOlderQueuedRun(t *testing.T) {
235239
 	if newRun.Status != actionsdb.WorkflowRunStatusQueued {
236240
 		t.Fatalf("new run status: got %s want queued", newRun.Status)
237241
 	}
242
+	assertDomainEventCounts(t, f.pool, f.repoID, map[string]int64{
243
+		"workflow_run": 3,
244
+		"workflow_job": 3,
245
+	})
246
+}
247
+
248
+func assertDomainEventCounts(t *testing.T, db actionsdb.DBTX, repoID int64, want map[string]int64) {
249
+	t.Helper()
250
+	for kind, n := range want {
251
+		var got int64
252
+		if err := db.QueryRow(context.Background(),
253
+			`SELECT count(*) FROM domain_events WHERE repo_id = $1 AND kind = $2`,
254
+			repoID, kind,
255
+		).Scan(&got); err != nil {
256
+			t.Fatalf("count domain events %s: %v", kind, err)
257
+		}
258
+		if got != n {
259
+			t.Fatalf("domain_events[%s] = %d, want %d", kind, got, n)
260
+		}
261
+	}
238262
 }
239263
 
240264
 func TestClaimQueuedWorkflowJob_BlocksYoungerConcurrencyRun(t *testing.T) {
internal/admin/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/auth/audit/audit.gomodified
17 lines changed — click to load
@@ -92,6 +92,17 @@ const (
9292
 	ActionActionsVariableSet     Action = "actions_variable_set"
9393
 	ActionActionsVariableDeleted Action = "actions_variable_deleted"
9494
 
95
+	// S41h — Actions run/job lifecycle. Metadata must stay structural:
96
+	// run/job ids, status, conclusion, workflow path/name. Never include
97
+	// event payloads, env, logs, permissions, tokens, or secret values.
98
+	ActionWorkflowRunCreated   Action = "workflow_run_created"
99
+	ActionWorkflowRunStarted   Action = "workflow_run_started"
100
+	ActionWorkflowRunCompleted Action = "workflow_run_completed"
101
+	ActionWorkflowJobCreated   Action = "workflow_job_created"
102
+	ActionWorkflowJobStarted   Action = "workflow_job_started"
103
+	ActionWorkflowJobCompleted Action = "workflow_job_completed"
104
+	ActionWorkflowJobCancelled Action = "workflow_job_cancelled"
105
+
95106
 	// S34 — site admin actions. Always recorded with the real admin's
96107
 	// id in actor_id; impersonation flows additionally carry the
97108
 	// impersonated user's id in meta.impersonated_user_id.
internal/auth/policy/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/billing/billing.goadded
309 lines changed — click to load
@@ -0,0 +1,309 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package billing owns local paid-organization state. It stores Stripe
4
+// identifiers and derived subscription state, but it does not call
5
+// Stripe directly; webhook/API integration lands in SP03.
6
+package billing
7
+
8
+import (
9
+	"context"
10
+	"encoding/json"
11
+	"errors"
12
+	"fmt"
13
+	"strings"
14
+	"time"
15
+
16
+	"github.com/jackc/pgx/v5"
17
+	"github.com/jackc/pgx/v5/pgtype"
18
+	"github.com/jackc/pgx/v5/pgxpool"
19
+
20
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
21
+)
22
+
23
+type Deps struct {
24
+	Pool *pgxpool.Pool
25
+}
26
+
27
+type (
28
+	Plan               = billingdb.OrgPlan
29
+	SubscriptionStatus = billingdb.BillingSubscriptionStatus
30
+	State              = billingdb.OrgBillingState
31
+)
32
+
33
+const (
34
+	PlanFree       = billingdb.OrgPlanFree
35
+	PlanTeam       = billingdb.OrgPlanTeam
36
+	PlanEnterprise = billingdb.OrgPlanEnterprise
37
+
38
+	SubscriptionStatusNone       = billingdb.BillingSubscriptionStatusNone
39
+	SubscriptionStatusIncomplete = billingdb.BillingSubscriptionStatusIncomplete
40
+	SubscriptionStatusTrialing   = billingdb.BillingSubscriptionStatusTrialing
41
+	SubscriptionStatusActive     = billingdb.BillingSubscriptionStatusActive
42
+	SubscriptionStatusPastDue    = billingdb.BillingSubscriptionStatusPastDue
43
+	SubscriptionStatusCanceled   = billingdb.BillingSubscriptionStatusCanceled
44
+	SubscriptionStatusUnpaid     = billingdb.BillingSubscriptionStatusUnpaid
45
+	SubscriptionStatusPaused     = billingdb.BillingSubscriptionStatusPaused
46
+)
47
+
48
+var (
49
+	ErrPoolRequired     = errors.New("billing: pool is required")
50
+	ErrOrgIDRequired    = errors.New("billing: org id is required")
51
+	ErrStripeCustomerID = errors.New("billing: stripe customer id is required")
52
+	ErrInvalidPlan      = errors.New("billing: invalid plan")
53
+	ErrInvalidStatus    = errors.New("billing: invalid subscription status")
54
+	ErrInvalidSeatCount = errors.New("billing: seat counts cannot be negative")
55
+	ErrWebhookEventID   = errors.New("billing: webhook event id is required")
56
+	ErrWebhookEventType = errors.New("billing: webhook event type is required")
57
+	ErrWebhookPayload   = errors.New("billing: webhook payload must be a JSON object")
58
+)
59
+
60
+// SubscriptionSnapshot is the local projection of a provider
61
+// subscription event. Provider-specific conversion belongs in SP03.
62
+type SubscriptionSnapshot struct {
63
+	OrgID                    int64
64
+	Plan                     Plan
65
+	Status                   SubscriptionStatus
66
+	StripeSubscriptionID     string
67
+	StripeSubscriptionItemID string
68
+	CurrentPeriodStart       time.Time
69
+	CurrentPeriodEnd         time.Time
70
+	CancelAtPeriodEnd        bool
71
+	TrialEnd                 time.Time
72
+	CanceledAt               time.Time
73
+	LastWebhookEventID       string
74
+}
75
+
76
+type SeatSnapshot struct {
77
+	OrgID                int64
78
+	StripeSubscriptionID string
79
+	ActiveMembers        int
80
+	BillableSeats        int
81
+	Source               string
82
+}
83
+
84
+type WebhookEvent struct {
85
+	ProviderEventID string
86
+	EventType       string
87
+	APIVersion      string
88
+	Payload         []byte
89
+}
90
+
91
+func GetOrgBillingState(ctx context.Context, deps Deps, orgID int64) (State, error) {
92
+	if err := validateDeps(deps); err != nil {
93
+		return State{}, err
94
+	}
95
+	if orgID == 0 {
96
+		return State{}, ErrOrgIDRequired
97
+	}
98
+	return billingdb.New().GetOrgBillingState(ctx, deps.Pool, orgID)
99
+}
100
+
101
+func SetStripeCustomer(ctx context.Context, deps Deps, orgID int64, customerID string) (State, error) {
102
+	if err := validateDeps(deps); err != nil {
103
+		return State{}, err
104
+	}
105
+	if orgID == 0 {
106
+		return State{}, ErrOrgIDRequired
107
+	}
108
+	customerID = strings.TrimSpace(customerID)
109
+	if customerID == "" {
110
+		return State{}, ErrStripeCustomerID
111
+	}
112
+	return billingdb.New().SetStripeCustomer(ctx, deps.Pool, billingdb.SetStripeCustomerParams{
113
+		OrgID:            orgID,
114
+		StripeCustomerID: pgText(customerID),
115
+	})
116
+}
117
+
118
+func ApplySubscriptionSnapshot(ctx context.Context, deps Deps, snap SubscriptionSnapshot) (State, error) {
119
+	if err := validateDeps(deps); err != nil {
120
+		return State{}, err
121
+	}
122
+	if snap.OrgID == 0 {
123
+		return State{}, ErrOrgIDRequired
124
+	}
125
+	if !validPlan(snap.Plan) {
126
+		return State{}, fmt.Errorf("%w: %q", ErrInvalidPlan, snap.Plan)
127
+	}
128
+	if !validStatus(snap.Status) {
129
+		return State{}, fmt.Errorf("%w: %q", ErrInvalidStatus, snap.Status)
130
+	}
131
+	row, err := billingdb.New().ApplySubscriptionSnapshot(ctx, deps.Pool, billingdb.ApplySubscriptionSnapshotParams{
132
+		OrgID:                    snap.OrgID,
133
+		Plan:                     snap.Plan,
134
+		SubscriptionStatus:       snap.Status,
135
+		StripeSubscriptionID:     pgText(snap.StripeSubscriptionID),
136
+		StripeSubscriptionItemID: pgText(snap.StripeSubscriptionItemID),
137
+		CurrentPeriodStart:       pgTime(snap.CurrentPeriodStart),
138
+		CurrentPeriodEnd:         pgTime(snap.CurrentPeriodEnd),
139
+		CancelAtPeriodEnd:        snap.CancelAtPeriodEnd,
140
+		TrialEnd:                 pgTime(snap.TrialEnd),
141
+		CanceledAt:               pgTime(snap.CanceledAt),
142
+		LastWebhookEventID:       strings.TrimSpace(snap.LastWebhookEventID),
143
+	})
144
+	if err != nil {
145
+		return State{}, err
146
+	}
147
+	return stateFromApply(row), nil
148
+}
149
+
150
+func RecordWebhookEvent(ctx context.Context, deps Deps, event WebhookEvent) (billingdb.BillingWebhookEvent, bool, error) {
151
+	if err := validateDeps(deps); err != nil {
152
+		return billingdb.BillingWebhookEvent{}, false, err
153
+	}
154
+	event.ProviderEventID = strings.TrimSpace(event.ProviderEventID)
155
+	event.EventType = strings.TrimSpace(event.EventType)
156
+	event.APIVersion = strings.TrimSpace(event.APIVersion)
157
+	if event.ProviderEventID == "" {
158
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventID
159
+	}
160
+	if event.EventType == "" {
161
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventType
162
+	}
163
+	if !jsonObject(event.Payload) {
164
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookPayload
165
+	}
166
+	row, err := billingdb.New().CreateWebhookEventReceipt(ctx, deps.Pool, billingdb.CreateWebhookEventReceiptParams{
167
+		ProviderEventID: event.ProviderEventID,
168
+		EventType:       event.EventType,
169
+		ApiVersion:      event.APIVersion,
170
+		Payload:         event.Payload,
171
+	})
172
+	if err != nil {
173
+		if errors.Is(err, pgx.ErrNoRows) {
174
+			return billingdb.BillingWebhookEvent{}, false, nil
175
+		}
176
+		return billingdb.BillingWebhookEvent{}, false, err
177
+	}
178
+	return row, true, nil
179
+}
180
+
181
+func SyncSeatSnapshot(ctx context.Context, deps Deps, snap SeatSnapshot) (billingdb.BillingSeatSnapshot, error) {
182
+	if err := validateDeps(deps); err != nil {
183
+		return billingdb.BillingSeatSnapshot{}, err
184
+	}
185
+	if snap.OrgID == 0 {
186
+		return billingdb.BillingSeatSnapshot{}, ErrOrgIDRequired
187
+	}
188
+	if snap.ActiveMembers < 0 || snap.BillableSeats < 0 {
189
+		return billingdb.BillingSeatSnapshot{}, ErrInvalidSeatCount
190
+	}
191
+	source := strings.TrimSpace(snap.Source)
192
+	if source == "" {
193
+		source = "local"
194
+	}
195
+	row, err := billingdb.New().CreateSeatSnapshot(ctx, deps.Pool, billingdb.CreateSeatSnapshotParams{
196
+		OrgID:                snap.OrgID,
197
+		StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
198
+		ActiveMembers:        int32(snap.ActiveMembers),
199
+		BillableSeats:        int32(snap.BillableSeats),
200
+		Source:               source,
201
+	})
202
+	if err != nil {
203
+		return billingdb.BillingSeatSnapshot{}, err
204
+	}
205
+	return billingdb.BillingSeatSnapshot(row), nil
206
+}
207
+
208
+func MarkPastDue(ctx context.Context, deps Deps, orgID int64, graceUntil time.Time, lastWebhookEventID string) (State, error) {
209
+	if err := validateDeps(deps); err != nil {
210
+		return State{}, err
211
+	}
212
+	if orgID == 0 {
213
+		return State{}, ErrOrgIDRequired
214
+	}
215
+	return billingdb.New().MarkPastDue(ctx, deps.Pool, billingdb.MarkPastDueParams{
216
+		OrgID:              orgID,
217
+		GraceUntil:         pgTime(graceUntil),
218
+		LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
219
+	})
220
+}
221
+
222
+func MarkCanceled(ctx context.Context, deps Deps, orgID int64, lastWebhookEventID string) (State, error) {
223
+	if err := validateDeps(deps); err != nil {
224
+		return State{}, err
225
+	}
226
+	if orgID == 0 {
227
+		return State{}, ErrOrgIDRequired
228
+	}
229
+	row, err := billingdb.New().MarkCanceled(ctx, deps.Pool, billingdb.MarkCanceledParams{
230
+		OrgID:              orgID,
231
+		LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
232
+	})
233
+	if err != nil {
234
+		return State{}, err
235
+	}
236
+	return stateFromCanceled(row), nil
237
+}
238
+
239
+func ClearBillingLock(ctx context.Context, deps Deps, orgID int64) (State, error) {
240
+	if err := validateDeps(deps); err != nil {
241
+		return State{}, err
242
+	}
243
+	if orgID == 0 {
244
+		return State{}, ErrOrgIDRequired
245
+	}
246
+	row, err := billingdb.New().ClearBillingLock(ctx, deps.Pool, orgID)
247
+	if err != nil {
248
+		return State{}, err
249
+	}
250
+	return stateFromClear(row), nil
251
+}
252
+
253
+func validateDeps(deps Deps) error {
254
+	if deps.Pool == nil {
255
+		return ErrPoolRequired
256
+	}
257
+	return nil
258
+}
259
+
260
+func validPlan(plan Plan) bool {
261
+	switch plan {
262
+	case PlanFree, PlanTeam, PlanEnterprise:
263
+		return true
264
+	default:
265
+		return false
266
+	}
267
+}
268
+
269
+func validStatus(status SubscriptionStatus) bool {
270
+	switch status {
271
+	case SubscriptionStatusNone,
272
+		SubscriptionStatusIncomplete,
273
+		SubscriptionStatusTrialing,
274
+		SubscriptionStatusActive,
275
+		SubscriptionStatusPastDue,
276
+		SubscriptionStatusCanceled,
277
+		SubscriptionStatusUnpaid,
278
+		SubscriptionStatusPaused:
279
+		return true
280
+	default:
281
+		return false
282
+	}
283
+}
284
+
285
+func pgText(s string) pgtype.Text {
286
+	s = strings.TrimSpace(s)
287
+	return pgtype.Text{String: s, Valid: s != ""}
288
+}
289
+
290
+func pgTime(t time.Time) pgtype.Timestamptz {
291
+	return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()}
292
+}
293
+
294
+func jsonObject(payload []byte) bool {
295
+	var v map[string]any
296
+	return json.Unmarshal(payload, &v) == nil && v != nil
297
+}
298
+
299
+func stateFromApply(row billingdb.ApplySubscriptionSnapshotRow) State {
300
+	return State(row)
301
+}
302
+
303
+func stateFromCanceled(row billingdb.MarkCanceledRow) State {
304
+	return State(row)
305
+}
306
+
307
+func stateFromClear(row billingdb.ClearBillingLockRow) State {
308
+	return State(row)
309
+}
internal/billing/billing_test.goadded
208 lines changed — click to load
@@ -0,0 +1,208 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package billing_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/billing"
15
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/orgs"
17
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
19
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20
+)
21
+
22
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
23
+	"AAAAAAAAAAAAAAAA$" +
24
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
25
+
26
+func setup(t *testing.T) (*pgxpool.Pool, billing.Deps, orgsdb.Org) {
27
+	t.Helper()
28
+	pool := dbtest.NewTestDB(t)
29
+	ctx := context.Background()
30
+	u, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
31
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
32
+	})
33
+	if err != nil {
34
+		t.Fatalf("create user: %v", err)
35
+	}
36
+	odeps := orgs.Deps{
37
+		Pool:   pool,
38
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
39
+	}
40
+	org, err := orgs.Create(ctx, odeps, orgs.CreateParams{
41
+		Slug: "acme", DisplayName: "Acme Inc", CreatedByUserID: u.ID,
42
+	})
43
+	if err != nil {
44
+		t.Fatalf("create org: %v", err)
45
+	}
46
+	return pool, billing.Deps{Pool: pool}, org
47
+}
48
+
49
+func TestBillingStateTransitions(t *testing.T) {
50
+	pool, deps, org := setup(t)
51
+	ctx := context.Background()
52
+
53
+	state, err := billing.GetOrgBillingState(ctx, deps, org.ID)
54
+	if err != nil {
55
+		t.Fatalf("GetOrgBillingState: %v", err)
56
+	}
57
+	if state.Plan != billing.PlanFree || state.SubscriptionStatus != billing.SubscriptionStatusNone {
58
+		t.Fatalf("new org state: plan=%s status=%s", state.Plan, state.SubscriptionStatus)
59
+	}
60
+
61
+	state, err = billing.SetStripeCustomer(ctx, deps, org.ID, "cus_test")
62
+	if err != nil {
63
+		t.Fatalf("SetStripeCustomer: %v", err)
64
+	}
65
+	if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test" {
66
+		t.Fatalf("stripe customer not set: %+v", state.StripeCustomerID)
67
+	}
68
+
69
+	start := time.Now().UTC().Truncate(time.Second)
70
+	state, err = billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
71
+		OrgID:                    org.ID,
72
+		Plan:                     billing.PlanTeam,
73
+		Status:                   billing.SubscriptionStatusActive,
74
+		StripeSubscriptionID:     "sub_test",
75
+		StripeSubscriptionItemID: "si_test",
76
+		CurrentPeriodStart:       start,
77
+		CurrentPeriodEnd:         start.Add(30 * 24 * time.Hour),
78
+		LastWebhookEventID:       "evt_active",
79
+	})
80
+	if err != nil {
81
+		t.Fatalf("ApplySubscriptionSnapshot active: %v", err)
82
+	}
83
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusActive)
84
+	if state.LockedAt.Valid || state.LockReason.Valid {
85
+		t.Fatalf("active subscription should not be locked: %+v", state)
86
+	}
87
+	assertOrgPlan(t, pool, org.ID, orgsdb.OrgPlanTeam)
88
+
89
+	grace := start.Add(7 * 24 * time.Hour)
90
+	state, err = billing.MarkPastDue(ctx, deps, org.ID, grace, "evt_past_due")
91
+	if err != nil {
92
+		t.Fatalf("MarkPastDue: %v", err)
93
+	}
94
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusPastDue)
95
+	if !state.LockedAt.Valid || !state.LockReason.Valid || state.LockReason.BillingLockReason != billingdb.BillingLockReasonPastDue {
96
+		t.Fatalf("past_due should set lock fields: %+v", state)
97
+	}
98
+	if !state.GraceUntil.Valid {
99
+		t.Fatalf("past_due should set grace_until")
100
+	}
101
+
102
+	state, err = billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
103
+		OrgID:                    org.ID,
104
+		Plan:                     billing.PlanTeam,
105
+		Status:                   billing.SubscriptionStatusActive,
106
+		StripeSubscriptionID:     "sub_test",
107
+		StripeSubscriptionItemID: "si_test",
108
+		CurrentPeriodStart:       start,
109
+		CurrentPeriodEnd:         start.Add(30 * 24 * time.Hour),
110
+		LastWebhookEventID:       "evt_recovered",
111
+	})
112
+	if err != nil {
113
+		t.Fatalf("ApplySubscriptionSnapshot recovered: %v", err)
114
+	}
115
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusActive)
116
+	if state.LockedAt.Valid || state.LockReason.Valid || state.GraceUntil.Valid || state.PastDueAt.Valid {
117
+		t.Fatalf("recovered subscription should clear lock/grace/past_due: %+v", state)
118
+	}
119
+
120
+	state, err = billing.MarkCanceled(ctx, deps, org.ID, "evt_canceled")
121
+	if err != nil {
122
+		t.Fatalf("MarkCanceled: %v", err)
123
+	}
124
+	assertState(t, state, billing.PlanFree, billing.SubscriptionStatusCanceled)
125
+	if !state.LockedAt.Valid || !state.LockReason.Valid || state.LockReason.BillingLockReason != billingdb.BillingLockReasonCanceled {
126
+		t.Fatalf("canceled subscription should set canceled lock: %+v", state)
127
+	}
128
+	assertOrgPlan(t, pool, org.ID, orgsdb.OrgPlanFree)
129
+
130
+	state, err = billing.ClearBillingLock(ctx, deps, org.ID)
131
+	if err != nil {
132
+		t.Fatalf("ClearBillingLock: %v", err)
133
+	}
134
+	assertState(t, state, billing.PlanFree, billing.SubscriptionStatusNone)
135
+	if state.LockedAt.Valid || state.LockReason.Valid || state.GraceUntil.Valid {
136
+		t.Fatalf("free state should clear billing lock: %+v", state)
137
+	}
138
+}
139
+
140
+func TestRecordWebhookEventIsIdempotent(t *testing.T) {
141
+	_, deps, _ := setup(t)
142
+	ctx := context.Background()
143
+
144
+	event := billing.WebhookEvent{
145
+		ProviderEventID: "evt_test",
146
+		EventType:       "customer.subscription.updated",
147
+		APIVersion:      "2024-06-20",
148
+		Payload:         []byte(`{"id":"evt_test"}`),
149
+	}
150
+	row, created, err := billing.RecordWebhookEvent(ctx, deps, event)
151
+	if err != nil {
152
+		t.Fatalf("RecordWebhookEvent first: %v", err)
153
+	}
154
+	if !created || row.ProviderEventID != "evt_test" {
155
+		t.Fatalf("first receipt created=%v row=%+v", created, row)
156
+	}
157
+
158
+	_, created, err = billing.RecordWebhookEvent(ctx, deps, event)
159
+	if err != nil {
160
+		t.Fatalf("RecordWebhookEvent duplicate: %v", err)
161
+	}
162
+	if created {
163
+		t.Fatalf("duplicate receipt should not be created")
164
+	}
165
+}
166
+
167
+func TestSyncSeatSnapshotUpdatesBillingState(t *testing.T) {
168
+	_, deps, org := setup(t)
169
+	ctx := context.Background()
170
+
171
+	snap, err := billing.SyncSeatSnapshot(ctx, deps, billing.SeatSnapshot{
172
+		OrgID:                org.ID,
173
+		StripeSubscriptionID: "sub_test",
174
+		ActiveMembers:        2,
175
+		BillableSeats:        2,
176
+	})
177
+	if err != nil {
178
+		t.Fatalf("SyncSeatSnapshot: %v", err)
179
+	}
180
+	if snap.ActiveMembers != 2 || snap.BillableSeats != 2 || snap.Source != "local" {
181
+		t.Fatalf("unexpected snapshot: %+v", snap)
182
+	}
183
+	state, err := billing.GetOrgBillingState(ctx, deps, org.ID)
184
+	if err != nil {
185
+		t.Fatalf("GetOrgBillingState: %v", err)
186
+	}
187
+	if state.BillableSeats != 2 || !state.SeatSnapshotAt.Valid {
188
+		t.Fatalf("state did not record seat snapshot: %+v", state)
189
+	}
190
+}
191
+
192
+func assertState(t *testing.T, state billing.State, plan billing.Plan, status billing.SubscriptionStatus) {
193
+	t.Helper()
194
+	if state.Plan != plan || state.SubscriptionStatus != status {
195
+		t.Fatalf("state: want plan=%s status=%s, got plan=%s status=%s", plan, status, state.Plan, state.SubscriptionStatus)
196
+	}
197
+}
198
+
199
+func assertOrgPlan(t *testing.T, pool *pgxpool.Pool, orgID int64, want orgsdb.OrgPlan) {
200
+	t.Helper()
201
+	row, err := orgsdb.New().GetOrgByID(context.Background(), pool, orgID)
202
+	if err != nil {
203
+		t.Fatalf("GetOrgByID: %v", err)
204
+	}
205
+	if row.Plan != want {
206
+		t.Fatalf("org plan: want %s, got %s", want, row.Plan)
207
+	}
208
+}
internal/billing/queries/billing.sqladded
291 lines changed — click to load
@@ -0,0 +1,291 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- ─── org_billing_states ────────────────────────────────────────────
4
+
5
+-- name: GetOrgBillingState :one
6
+SELECT * FROM org_billing_states WHERE org_id = $1;
7
+
8
+-- name: SetStripeCustomer :one
9
+INSERT INTO org_billing_states (org_id, provider, stripe_customer_id)
10
+VALUES ($1, 'stripe', $2)
11
+ON CONFLICT (org_id) DO UPDATE
12
+   SET stripe_customer_id = EXCLUDED.stripe_customer_id,
13
+       provider = 'stripe',
14
+       updated_at = now()
15
+RETURNING *;
16
+
17
+-- name: ApplySubscriptionSnapshot :one
18
+WITH state AS (
19
+    INSERT INTO org_billing_states (
20
+        org_id,
21
+        provider,
22
+        plan,
23
+        subscription_status,
24
+        stripe_subscription_id,
25
+        stripe_subscription_item_id,
26
+        current_period_start,
27
+        current_period_end,
28
+        cancel_at_period_end,
29
+        trial_end,
30
+        canceled_at,
31
+        last_webhook_event_id,
32
+        past_due_at,
33
+        locked_at,
34
+        lock_reason,
35
+        grace_until
36
+    )
37
+    VALUES (
38
+        sqlc.arg(org_id)::bigint,
39
+        'stripe',
40
+        sqlc.arg(plan)::org_plan,
41
+        sqlc.arg(subscription_status)::billing_subscription_status,
42
+        sqlc.narg(stripe_subscription_id)::text,
43
+        sqlc.narg(stripe_subscription_item_id)::text,
44
+        sqlc.narg(current_period_start)::timestamptz,
45
+        sqlc.narg(current_period_end)::timestamptz,
46
+        sqlc.arg(cancel_at_period_end)::boolean,
47
+        sqlc.narg(trial_end)::timestamptz,
48
+        sqlc.narg(canceled_at)::timestamptz,
49
+        sqlc.arg(last_webhook_event_id)::text,
50
+        CASE
51
+            WHEN sqlc.arg(subscription_status)::billing_subscription_status = 'past_due' THEN now()
52
+            ELSE NULL
53
+        END,
54
+        NULL,
55
+        NULL,
56
+        NULL
57
+    )
58
+    ON CONFLICT (org_id) DO UPDATE
59
+       SET plan = EXCLUDED.plan,
60
+           subscription_status = EXCLUDED.subscription_status,
61
+           stripe_subscription_id = EXCLUDED.stripe_subscription_id,
62
+           stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
63
+           current_period_start = EXCLUDED.current_period_start,
64
+           current_period_end = EXCLUDED.current_period_end,
65
+           cancel_at_period_end = EXCLUDED.cancel_at_period_end,
66
+           trial_end = EXCLUDED.trial_end,
67
+           canceled_at = EXCLUDED.canceled_at,
68
+           last_webhook_event_id = EXCLUDED.last_webhook_event_id,
69
+           past_due_at = CASE
70
+               WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(org_billing_states.past_due_at, now())
71
+               ELSE NULL
72
+           END,
73
+           locked_at = NULL,
74
+           lock_reason = NULL,
75
+           grace_until = NULL,
76
+           updated_at = now()
77
+    RETURNING *
78
+), org_update AS (
79
+    UPDATE orgs
80
+       SET plan = sqlc.arg(plan)::org_plan,
81
+           updated_at = now()
82
+     WHERE id = sqlc.arg(org_id)::bigint
83
+    RETURNING id
84
+)
85
+SELECT * FROM state;
86
+
87
+-- name: MarkPastDue :one
88
+UPDATE org_billing_states
89
+   SET subscription_status = 'past_due',
90
+       past_due_at = COALESCE(past_due_at, now()),
91
+       locked_at = now(),
92
+       lock_reason = 'past_due',
93
+       grace_until = sqlc.narg(grace_until)::timestamptz,
94
+       last_webhook_event_id = sqlc.arg(last_webhook_event_id)::text,
95
+       updated_at = now()
96
+ WHERE org_id = sqlc.arg(org_id)::bigint
97
+RETURNING *;
98
+
99
+-- name: MarkCanceled :one
100
+WITH state AS (
101
+    UPDATE org_billing_states
102
+       SET plan = 'free',
103
+           subscription_status = 'canceled',
104
+           canceled_at = COALESCE(canceled_at, now()),
105
+           locked_at = now(),
106
+           lock_reason = 'canceled',
107
+           grace_until = NULL,
108
+           cancel_at_period_end = false,
109
+           last_webhook_event_id = sqlc.arg(last_webhook_event_id)::text,
110
+           updated_at = now()
111
+     WHERE org_id = sqlc.arg(org_id)::bigint
112
+    RETURNING *
113
+), org_update AS (
114
+    UPDATE orgs
115
+       SET plan = 'free',
116
+           updated_at = now()
117
+     WHERE id = sqlc.arg(org_id)::bigint
118
+    RETURNING id
119
+)
120
+SELECT * FROM state;
121
+
122
+-- name: ClearBillingLock :one
123
+WITH state AS (
124
+    UPDATE org_billing_states
125
+       SET plan = CASE
126
+               WHEN subscription_status = 'canceled' THEN 'free'
127
+               ELSE plan
128
+           END,
129
+           subscription_status = CASE
130
+               WHEN subscription_status = 'canceled' THEN 'none'
131
+               ELSE subscription_status
132
+           END,
133
+           locked_at = NULL,
134
+           lock_reason = NULL,
135
+           grace_until = NULL,
136
+           updated_at = now()
137
+     WHERE org_id = $1
138
+    RETURNING *
139
+), org_update AS (
140
+    UPDATE orgs
141
+       SET plan = state.plan,
142
+           updated_at = now()
143
+      FROM state
144
+     WHERE orgs.id = state.org_id
145
+    RETURNING orgs.id
146
+)
147
+SELECT * FROM state;
148
+
149
+-- ─── billing_seat_snapshots ────────────────────────────────────────
150
+
151
+-- name: CreateSeatSnapshot :one
152
+WITH snapshot AS (
153
+    INSERT INTO billing_seat_snapshots (
154
+        org_id,
155
+        provider,
156
+        stripe_subscription_id,
157
+        active_members,
158
+        billable_seats,
159
+        source
160
+    )
161
+    VALUES (
162
+        sqlc.arg(org_id)::bigint,
163
+        'stripe',
164
+        sqlc.narg(stripe_subscription_id)::text,
165
+        sqlc.arg(active_members)::integer,
166
+        sqlc.arg(billable_seats)::integer,
167
+        sqlc.arg(source)::text
168
+    )
169
+    RETURNING *
170
+), state AS (
171
+    INSERT INTO org_billing_states (org_id, billable_seats, seat_snapshot_at)
172
+    SELECT org_id, billable_seats, captured_at FROM snapshot
173
+    ON CONFLICT (org_id) DO UPDATE
174
+       SET billable_seats = EXCLUDED.billable_seats,
175
+           seat_snapshot_at = EXCLUDED.seat_snapshot_at,
176
+           updated_at = now()
177
+    RETURNING org_id
178
+)
179
+SELECT * FROM snapshot;
180
+
181
+-- name: ListSeatSnapshotsForOrg :many
182
+SELECT * FROM billing_seat_snapshots
183
+WHERE org_id = $1
184
+ORDER BY captured_at DESC, id DESC
185
+LIMIT $2;
186
+
187
+-- ─── billing_invoices ──────────────────────────────────────────────
188
+
189
+-- name: UpsertInvoice :one
190
+INSERT INTO billing_invoices (
191
+    org_id,
192
+    provider,
193
+    stripe_invoice_id,
194
+    stripe_customer_id,
195
+    stripe_subscription_id,
196
+    status,
197
+    number,
198
+    currency,
199
+    amount_due_cents,
200
+    amount_paid_cents,
201
+    amount_remaining_cents,
202
+    hosted_invoice_url,
203
+    invoice_pdf_url,
204
+    period_start,
205
+    period_end,
206
+    due_at,
207
+    paid_at,
208
+    voided_at
209
+)
210
+VALUES (
211
+    sqlc.arg(org_id)::bigint,
212
+    'stripe',
213
+    sqlc.arg(stripe_invoice_id)::text,
214
+    sqlc.arg(stripe_customer_id)::text,
215
+    sqlc.narg(stripe_subscription_id)::text,
216
+    sqlc.arg(status)::billing_invoice_status,
217
+    sqlc.arg(number)::text,
218
+    sqlc.arg(currency)::text,
219
+    sqlc.arg(amount_due_cents)::bigint,
220
+    sqlc.arg(amount_paid_cents)::bigint,
221
+    sqlc.arg(amount_remaining_cents)::bigint,
222
+    sqlc.arg(hosted_invoice_url)::text,
223
+    sqlc.arg(invoice_pdf_url)::text,
224
+    sqlc.narg(period_start)::timestamptz,
225
+    sqlc.narg(period_end)::timestamptz,
226
+    sqlc.narg(due_at)::timestamptz,
227
+    sqlc.narg(paid_at)::timestamptz,
228
+    sqlc.narg(voided_at)::timestamptz
229
+)
230
+ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
231
+   SET org_id = EXCLUDED.org_id,
232
+       stripe_customer_id = EXCLUDED.stripe_customer_id,
233
+       stripe_subscription_id = EXCLUDED.stripe_subscription_id,
234
+       status = EXCLUDED.status,
235
+       number = EXCLUDED.number,
236
+       currency = EXCLUDED.currency,
237
+       amount_due_cents = EXCLUDED.amount_due_cents,
238
+       amount_paid_cents = EXCLUDED.amount_paid_cents,
239
+       amount_remaining_cents = EXCLUDED.amount_remaining_cents,
240
+       hosted_invoice_url = EXCLUDED.hosted_invoice_url,
241
+       invoice_pdf_url = EXCLUDED.invoice_pdf_url,
242
+       period_start = EXCLUDED.period_start,
243
+       period_end = EXCLUDED.period_end,
244
+       due_at = EXCLUDED.due_at,
245
+       paid_at = EXCLUDED.paid_at,
246
+       voided_at = EXCLUDED.voided_at,
247
+       updated_at = now()
248
+RETURNING *;
249
+
250
+-- name: ListInvoicesForOrg :many
251
+SELECT * FROM billing_invoices
252
+WHERE org_id = $1
253
+ORDER BY created_at DESC, id DESC
254
+LIMIT $2;
255
+
256
+-- ─── billing_webhook_events ────────────────────────────────────────
257
+
258
+-- name: CreateWebhookEventReceipt :one
259
+INSERT INTO billing_webhook_events (
260
+    provider,
261
+    provider_event_id,
262
+    event_type,
263
+    api_version,
264
+    payload
265
+)
266
+VALUES (
267
+    'stripe',
268
+    sqlc.arg(provider_event_id)::text,
269
+    sqlc.arg(event_type)::text,
270
+    sqlc.arg(api_version)::text,
271
+    sqlc.arg(payload)::jsonb
272
+)
273
+ON CONFLICT (provider, provider_event_id) DO NOTHING
274
+RETURNING *;
275
+
276
+-- name: MarkWebhookEventProcessed :one
277
+UPDATE billing_webhook_events
278
+   SET processed_at = now(),
279
+       process_error = '',
280
+       processing_attempts = processing_attempts + 1
281
+ WHERE provider = 'stripe'
282
+   AND provider_event_id = $1
283
+RETURNING *;
284
+
285
+-- name: MarkWebhookEventFailed :one
286
+UPDATE billing_webhook_events
287
+   SET process_error = $2,
288
+       processing_attempts = processing_attempts + 1
289
+ WHERE provider = 'stripe'
290
+   AND provider_event_id = $1
291
+RETURNING *;
internal/billing/sqlc/billing.sql.goadded
865 lines changed — click to load
@@ -0,0 +1,865 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: billing.sql
5
+
6
+package billingdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const applySubscriptionSnapshot = `-- name: ApplySubscriptionSnapshot :one
15
+WITH state AS (
16
+    INSERT INTO org_billing_states (
17
+        org_id,
18
+        provider,
19
+        plan,
20
+        subscription_status,
21
+        stripe_subscription_id,
22
+        stripe_subscription_item_id,
23
+        current_period_start,
24
+        current_period_end,
25
+        cancel_at_period_end,
26
+        trial_end,
27
+        canceled_at,
28
+        last_webhook_event_id,
29
+        past_due_at,
30
+        locked_at,
31
+        lock_reason,
32
+        grace_until
33
+    )
34
+    VALUES (
35
+        $1::bigint,
36
+        'stripe',
37
+        $2::org_plan,
38
+        $3::billing_subscription_status,
39
+        $4::text,
40
+        $5::text,
41
+        $6::timestamptz,
42
+        $7::timestamptz,
43
+        $8::boolean,
44
+        $9::timestamptz,
45
+        $10::timestamptz,
46
+        $11::text,
47
+        CASE
48
+            WHEN $3::billing_subscription_status = 'past_due' THEN now()
49
+            ELSE NULL
50
+        END,
51
+        NULL,
52
+        NULL,
53
+        NULL
54
+    )
55
+    ON CONFLICT (org_id) DO UPDATE
56
+       SET plan = EXCLUDED.plan,
57
+           subscription_status = EXCLUDED.subscription_status,
58
+           stripe_subscription_id = EXCLUDED.stripe_subscription_id,
59
+           stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
60
+           current_period_start = EXCLUDED.current_period_start,
61
+           current_period_end = EXCLUDED.current_period_end,
62
+           cancel_at_period_end = EXCLUDED.cancel_at_period_end,
63
+           trial_end = EXCLUDED.trial_end,
64
+           canceled_at = EXCLUDED.canceled_at,
65
+           last_webhook_event_id = EXCLUDED.last_webhook_event_id,
66
+           past_due_at = CASE
67
+               WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(org_billing_states.past_due_at, now())
68
+               ELSE NULL
69
+           END,
70
+           locked_at = NULL,
71
+           lock_reason = NULL,
72
+           grace_until = NULL,
73
+           updated_at = now()
74
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
75
+), org_update AS (
76
+    UPDATE orgs
77
+       SET plan = $2::org_plan,
78
+           updated_at = now()
79
+     WHERE id = $1::bigint
80
+    RETURNING id
81
+)
82
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
83
+`
84
+
85
+type ApplySubscriptionSnapshotParams struct {
86
+	OrgID                    int64
87
+	Plan                     OrgPlan
88
+	SubscriptionStatus       BillingSubscriptionStatus
89
+	StripeSubscriptionID     pgtype.Text
90
+	StripeSubscriptionItemID pgtype.Text
91
+	CurrentPeriodStart       pgtype.Timestamptz
92
+	CurrentPeriodEnd         pgtype.Timestamptz
93
+	CancelAtPeriodEnd        bool
94
+	TrialEnd                 pgtype.Timestamptz
95
+	CanceledAt               pgtype.Timestamptz
96
+	LastWebhookEventID       string
97
+}
98
+
99
+type ApplySubscriptionSnapshotRow struct {
100
+	OrgID                    int64
101
+	Provider                 BillingProvider
102
+	StripeCustomerID         pgtype.Text
103
+	StripeSubscriptionID     pgtype.Text
104
+	StripeSubscriptionItemID pgtype.Text
105
+	Plan                     OrgPlan
106
+	SubscriptionStatus       BillingSubscriptionStatus
107
+	BillableSeats            int32
108
+	SeatSnapshotAt           pgtype.Timestamptz
109
+	CurrentPeriodStart       pgtype.Timestamptz
110
+	CurrentPeriodEnd         pgtype.Timestamptz
111
+	CancelAtPeriodEnd        bool
112
+	TrialEnd                 pgtype.Timestamptz
113
+	PastDueAt                pgtype.Timestamptz
114
+	CanceledAt               pgtype.Timestamptz
115
+	LockedAt                 pgtype.Timestamptz
116
+	LockReason               NullBillingLockReason
117
+	GraceUntil               pgtype.Timestamptz
118
+	LastWebhookEventID       string
119
+	CreatedAt                pgtype.Timestamptz
120
+	UpdatedAt                pgtype.Timestamptz
121
+}
122
+
123
+func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error) {
124
+	row := db.QueryRow(ctx, applySubscriptionSnapshot,
125
+		arg.OrgID,
126
+		arg.Plan,
127
+		arg.SubscriptionStatus,
128
+		arg.StripeSubscriptionID,
129
+		arg.StripeSubscriptionItemID,
130
+		arg.CurrentPeriodStart,
131
+		arg.CurrentPeriodEnd,
132
+		arg.CancelAtPeriodEnd,
133
+		arg.TrialEnd,
134
+		arg.CanceledAt,
135
+		arg.LastWebhookEventID,
136
+	)
137
+	var i ApplySubscriptionSnapshotRow
138
+	err := row.Scan(
139
+		&i.OrgID,
140
+		&i.Provider,
141
+		&i.StripeCustomerID,
142
+		&i.StripeSubscriptionID,
143
+		&i.StripeSubscriptionItemID,
144
+		&i.Plan,
145
+		&i.SubscriptionStatus,
146
+		&i.BillableSeats,
147
+		&i.SeatSnapshotAt,
148
+		&i.CurrentPeriodStart,
149
+		&i.CurrentPeriodEnd,
150
+		&i.CancelAtPeriodEnd,
151
+		&i.TrialEnd,
152
+		&i.PastDueAt,
153
+		&i.CanceledAt,
154
+		&i.LockedAt,
155
+		&i.LockReason,
156
+		&i.GraceUntil,
157
+		&i.LastWebhookEventID,
158
+		&i.CreatedAt,
159
+		&i.UpdatedAt,
160
+	)
161
+	return i, err
162
+}
163
+
164
+const clearBillingLock = `-- name: ClearBillingLock :one
165
+WITH state AS (
166
+    UPDATE org_billing_states
167
+       SET plan = CASE
168
+               WHEN subscription_status = 'canceled' THEN 'free'
169
+               ELSE plan
170
+           END,
171
+           subscription_status = CASE
172
+               WHEN subscription_status = 'canceled' THEN 'none'
173
+               ELSE subscription_status
174
+           END,
175
+           locked_at = NULL,
176
+           lock_reason = NULL,
177
+           grace_until = NULL,
178
+           updated_at = now()
179
+     WHERE org_id = $1
180
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
181
+), org_update AS (
182
+    UPDATE orgs
183
+       SET plan = state.plan,
184
+           updated_at = now()
185
+      FROM state
186
+     WHERE orgs.id = state.org_id
187
+    RETURNING orgs.id
188
+)
189
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
190
+`
191
+
192
+type ClearBillingLockRow struct {
193
+	OrgID                    int64
194
+	Provider                 BillingProvider
195
+	StripeCustomerID         pgtype.Text
196
+	StripeSubscriptionID     pgtype.Text
197
+	StripeSubscriptionItemID pgtype.Text
198
+	Plan                     OrgPlan
199
+	SubscriptionStatus       BillingSubscriptionStatus
200
+	BillableSeats            int32
201
+	SeatSnapshotAt           pgtype.Timestamptz
202
+	CurrentPeriodStart       pgtype.Timestamptz
203
+	CurrentPeriodEnd         pgtype.Timestamptz
204
+	CancelAtPeriodEnd        bool
205
+	TrialEnd                 pgtype.Timestamptz
206
+	PastDueAt                pgtype.Timestamptz
207
+	CanceledAt               pgtype.Timestamptz
208
+	LockedAt                 pgtype.Timestamptz
209
+	LockReason               NullBillingLockReason
210
+	GraceUntil               pgtype.Timestamptz
211
+	LastWebhookEventID       string
212
+	CreatedAt                pgtype.Timestamptz
213
+	UpdatedAt                pgtype.Timestamptz
214
+}
215
+
216
+func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error) {
217
+	row := db.QueryRow(ctx, clearBillingLock, orgID)
218
+	var i ClearBillingLockRow
219
+	err := row.Scan(
220
+		&i.OrgID,
221
+		&i.Provider,
222
+		&i.StripeCustomerID,
223
+		&i.StripeSubscriptionID,
224
+		&i.StripeSubscriptionItemID,
225
+		&i.Plan,
226
+		&i.SubscriptionStatus,
227
+		&i.BillableSeats,
228
+		&i.SeatSnapshotAt,
229
+		&i.CurrentPeriodStart,
230
+		&i.CurrentPeriodEnd,
231
+		&i.CancelAtPeriodEnd,
232
+		&i.TrialEnd,
233
+		&i.PastDueAt,
234
+		&i.CanceledAt,
235
+		&i.LockedAt,
236
+		&i.LockReason,
237
+		&i.GraceUntil,
238
+		&i.LastWebhookEventID,
239
+		&i.CreatedAt,
240
+		&i.UpdatedAt,
241
+	)
242
+	return i, err
243
+}
244
+
245
+const createSeatSnapshot = `-- name: CreateSeatSnapshot :one
246
+
247
+WITH snapshot AS (
248
+    INSERT INTO billing_seat_snapshots (
249
+        org_id,
250
+        provider,
251
+        stripe_subscription_id,
252
+        active_members,
253
+        billable_seats,
254
+        source
255
+    )
256
+    VALUES (
257
+        $1::bigint,
258
+        'stripe',
259
+        $2::text,
260
+        $3::integer,
261
+        $4::integer,
262
+        $5::text
263
+    )
264
+    RETURNING id, org_id, provider, stripe_subscription_id, active_members, billable_seats, source, captured_at
265
+), state AS (
266
+    INSERT INTO org_billing_states (org_id, billable_seats, seat_snapshot_at)
267
+    SELECT org_id, billable_seats, captured_at FROM snapshot
268
+    ON CONFLICT (org_id) DO UPDATE
269
+       SET billable_seats = EXCLUDED.billable_seats,
270
+           seat_snapshot_at = EXCLUDED.seat_snapshot_at,
271
+           updated_at = now()
272
+    RETURNING org_id
273
+)
274
+SELECT id, org_id, provider, stripe_subscription_id, active_members, billable_seats, source, captured_at FROM snapshot
275
+`
276
+
277
+type CreateSeatSnapshotParams struct {
278
+	OrgID                int64
279
+	StripeSubscriptionID pgtype.Text
280
+	ActiveMembers        int32
281
+	BillableSeats        int32
282
+	Source               string
283
+}
284
+
285
+type CreateSeatSnapshotRow struct {
286
+	ID                   int64
287
+	OrgID                int64
288
+	Provider             BillingProvider
289
+	StripeSubscriptionID pgtype.Text
290
+	ActiveMembers        int32
291
+	BillableSeats        int32
292
+	Source               string
293
+	CapturedAt           pgtype.Timestamptz
294
+}
295
+
296
+// ─── billing_seat_snapshots ────────────────────────────────────────
297
+func (q *Queries) CreateSeatSnapshot(ctx context.Context, db DBTX, arg CreateSeatSnapshotParams) (CreateSeatSnapshotRow, error) {
298
+	row := db.QueryRow(ctx, createSeatSnapshot,
299
+		arg.OrgID,
300
+		arg.StripeSubscriptionID,
301
+		arg.ActiveMembers,
302
+		arg.BillableSeats,
303
+		arg.Source,
304
+	)
305
+	var i CreateSeatSnapshotRow
306
+	err := row.Scan(
307
+		&i.ID,
308
+		&i.OrgID,
309
+		&i.Provider,
310
+		&i.StripeSubscriptionID,
311
+		&i.ActiveMembers,
312
+		&i.BillableSeats,
313
+		&i.Source,
314
+		&i.CapturedAt,
315
+	)
316
+	return i, err
317
+}
318
+
319
+const createWebhookEventReceipt = `-- name: CreateWebhookEventReceipt :one
320
+
321
+INSERT INTO billing_webhook_events (
322
+    provider,
323
+    provider_event_id,
324
+    event_type,
325
+    api_version,
326
+    payload
327
+)
328
+VALUES (
329
+    'stripe',
330
+    $1::text,
331
+    $2::text,
332
+    $3::text,
333
+    $4::jsonb
334
+)
335
+ON CONFLICT (provider, provider_event_id) DO NOTHING
336
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
337
+`
338
+
339
+type CreateWebhookEventReceiptParams struct {
340
+	ProviderEventID string
341
+	EventType       string
342
+	ApiVersion      string
343
+	Payload         []byte
344
+}
345
+
346
+// ─── billing_webhook_events ────────────────────────────────────────
347
+func (q *Queries) CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg CreateWebhookEventReceiptParams) (BillingWebhookEvent, error) {
348
+	row := db.QueryRow(ctx, createWebhookEventReceipt,
349
+		arg.ProviderEventID,
350
+		arg.EventType,
351
+		arg.ApiVersion,
352
+		arg.Payload,
353
+	)
354
+	var i BillingWebhookEvent
355
+	err := row.Scan(
356
+		&i.ID,
357
+		&i.Provider,
358
+		&i.ProviderEventID,
359
+		&i.EventType,
360
+		&i.ApiVersion,
361
+		&i.Payload,
362
+		&i.ReceivedAt,
363
+		&i.ProcessedAt,
364
+		&i.ProcessError,
365
+		&i.ProcessingAttempts,
366
+	)
367
+	return i, err
368
+}
369
+
370
+const getOrgBillingState = `-- name: GetOrgBillingState :one
371
+
372
+
373
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM org_billing_states WHERE org_id = $1
374
+`
375
+
376
+// SPDX-License-Identifier: AGPL-3.0-or-later
377
+// ─── org_billing_states ────────────────────────────────────────────
378
+func (q *Queries) GetOrgBillingState(ctx context.Context, db DBTX, orgID int64) (OrgBillingState, error) {
379
+	row := db.QueryRow(ctx, getOrgBillingState, orgID)
380
+	var i OrgBillingState
381
+	err := row.Scan(
382
+		&i.OrgID,
383
+		&i.Provider,
384
+		&i.StripeCustomerID,
385
+		&i.StripeSubscriptionID,
386
+		&i.StripeSubscriptionItemID,
387
+		&i.Plan,
388
+		&i.SubscriptionStatus,
389
+		&i.BillableSeats,
390
+		&i.SeatSnapshotAt,
391
+		&i.CurrentPeriodStart,
392
+		&i.CurrentPeriodEnd,
393
+		&i.CancelAtPeriodEnd,
394
+		&i.TrialEnd,
395
+		&i.PastDueAt,
396
+		&i.CanceledAt,
397
+		&i.LockedAt,
398
+		&i.LockReason,
399
+		&i.GraceUntil,
400
+		&i.LastWebhookEventID,
401
+		&i.CreatedAt,
402
+		&i.UpdatedAt,
403
+	)
404
+	return i, err
405
+}
406
+
407
+const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many
408
+SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at FROM billing_invoices
409
+WHERE org_id = $1
410
+ORDER BY created_at DESC, id DESC
411
+LIMIT $2
412
+`
413
+
414
+type ListInvoicesForOrgParams struct {
415
+	OrgID int64
416
+	Limit int32
417
+}
418
+
419
+func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error) {
420
+	rows, err := db.Query(ctx, listInvoicesForOrg, arg.OrgID, arg.Limit)
421
+	if err != nil {
422
+		return nil, err
423
+	}
424
+	defer rows.Close()
425
+	items := []BillingInvoice{}
426
+	for rows.Next() {
427
+		var i BillingInvoice
428
+		if err := rows.Scan(
429
+			&i.ID,
430
+			&i.OrgID,
431
+			&i.Provider,
432
+			&i.StripeInvoiceID,
433
+			&i.StripeCustomerID,
434
+			&i.StripeSubscriptionID,
435
+			&i.Status,
436
+			&i.Number,
437
+			&i.Currency,
438
+			&i.AmountDueCents,
439
+			&i.AmountPaidCents,
440
+			&i.AmountRemainingCents,
441
+			&i.HostedInvoiceUrl,
442
+			&i.InvoicePdfUrl,
443
+			&i.PeriodStart,
444
+			&i.PeriodEnd,
445
+			&i.DueAt,
446
+			&i.PaidAt,
447
+			&i.VoidedAt,
448
+			&i.CreatedAt,
449
+			&i.UpdatedAt,
450
+		); err != nil {
451
+			return nil, err
452
+		}
453
+		items = append(items, i)
454
+	}
455
+	if err := rows.Err(); err != nil {
456
+		return nil, err
457
+	}
458
+	return items, nil
459
+}
460
+
461
+const listSeatSnapshotsForOrg = `-- name: ListSeatSnapshotsForOrg :many
462
+SELECT id, org_id, provider, stripe_subscription_id, active_members, billable_seats, source, captured_at FROM billing_seat_snapshots
463
+WHERE org_id = $1
464
+ORDER BY captured_at DESC, id DESC
465
+LIMIT $2
466
+`
467
+
468
+type ListSeatSnapshotsForOrgParams struct {
469
+	OrgID int64
470
+	Limit int32
471
+}
472
+
473
+func (q *Queries) ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error) {
474
+	rows, err := db.Query(ctx, listSeatSnapshotsForOrg, arg.OrgID, arg.Limit)
475
+	if err != nil {
476
+		return nil, err
477
+	}
478
+	defer rows.Close()
479
+	items := []BillingSeatSnapshot{}
480
+	for rows.Next() {
481
+		var i BillingSeatSnapshot
482
+		if err := rows.Scan(
483
+			&i.ID,
484
+			&i.OrgID,
485
+			&i.Provider,
486
+			&i.StripeSubscriptionID,
487
+			&i.ActiveMembers,
488
+			&i.BillableSeats,
489
+			&i.Source,
490
+			&i.CapturedAt,
491
+		); err != nil {
492
+			return nil, err
493
+		}
494
+		items = append(items, i)
495
+	}
496
+	if err := rows.Err(); err != nil {
497
+		return nil, err
498
+	}
499
+	return items, nil
500
+}
501
+
502
+const markCanceled = `-- name: MarkCanceled :one
503
+WITH state AS (
504
+    UPDATE org_billing_states
505
+       SET plan = 'free',
506
+           subscription_status = 'canceled',
507
+           canceled_at = COALESCE(canceled_at, now()),
508
+           locked_at = now(),
509
+           lock_reason = 'canceled',
510
+           grace_until = NULL,
511
+           cancel_at_period_end = false,
512
+           last_webhook_event_id = $1::text,
513
+           updated_at = now()
514
+     WHERE org_id = $2::bigint
515
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
516
+), org_update AS (
517
+    UPDATE orgs
518
+       SET plan = 'free',
519
+           updated_at = now()
520
+     WHERE id = $2::bigint
521
+    RETURNING id
522
+)
523
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
524
+`
525
+
526
+type MarkCanceledParams struct {
527
+	LastWebhookEventID string
528
+	OrgID              int64
529
+}
530
+
531
+type MarkCanceledRow struct {
532
+	OrgID                    int64
533
+	Provider                 BillingProvider
534
+	StripeCustomerID         pgtype.Text
535
+	StripeSubscriptionID     pgtype.Text
536
+	StripeSubscriptionItemID pgtype.Text
537
+	Plan                     OrgPlan
538
+	SubscriptionStatus       BillingSubscriptionStatus
539
+	BillableSeats            int32
540
+	SeatSnapshotAt           pgtype.Timestamptz
541
+	CurrentPeriodStart       pgtype.Timestamptz
542
+	CurrentPeriodEnd         pgtype.Timestamptz
543
+	CancelAtPeriodEnd        bool
544
+	TrialEnd                 pgtype.Timestamptz
545
+	PastDueAt                pgtype.Timestamptz
546
+	CanceledAt               pgtype.Timestamptz
547
+	LockedAt                 pgtype.Timestamptz
548
+	LockReason               NullBillingLockReason
549
+	GraceUntil               pgtype.Timestamptz
550
+	LastWebhookEventID       string
551
+	CreatedAt                pgtype.Timestamptz
552
+	UpdatedAt                pgtype.Timestamptz
553
+}
554
+
555
+func (q *Queries) MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error) {
556
+	row := db.QueryRow(ctx, markCanceled, arg.LastWebhookEventID, arg.OrgID)
557
+	var i MarkCanceledRow
558
+	err := row.Scan(
559
+		&i.OrgID,
560
+		&i.Provider,
561
+		&i.StripeCustomerID,
562
+		&i.StripeSubscriptionID,
563
+		&i.StripeSubscriptionItemID,
564
+		&i.Plan,
565
+		&i.SubscriptionStatus,
566
+		&i.BillableSeats,
567
+		&i.SeatSnapshotAt,
568
+		&i.CurrentPeriodStart,
569
+		&i.CurrentPeriodEnd,
570
+		&i.CancelAtPeriodEnd,
571
+		&i.TrialEnd,
572
+		&i.PastDueAt,
573
+		&i.CanceledAt,
574
+		&i.LockedAt,
575
+		&i.LockReason,
576
+		&i.GraceUntil,
577
+		&i.LastWebhookEventID,
578
+		&i.CreatedAt,
579
+		&i.UpdatedAt,
580
+	)
581
+	return i, err
582
+}
583
+
584
+const markPastDue = `-- name: MarkPastDue :one
585
+UPDATE org_billing_states
586
+   SET subscription_status = 'past_due',
587
+       past_due_at = COALESCE(past_due_at, now()),
588
+       locked_at = now(),
589
+       lock_reason = 'past_due',
590
+       grace_until = $1::timestamptz,
591
+       last_webhook_event_id = $2::text,
592
+       updated_at = now()
593
+ WHERE org_id = $3::bigint
594
+RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
595
+`
596
+
597
+type MarkPastDueParams struct {
598
+	GraceUntil         pgtype.Timestamptz
599
+	LastWebhookEventID string
600
+	OrgID              int64
601
+}
602
+
603
+func (q *Queries) MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error) {
604
+	row := db.QueryRow(ctx, markPastDue, arg.GraceUntil, arg.LastWebhookEventID, arg.OrgID)
605
+	var i OrgBillingState
606
+	err := row.Scan(
607
+		&i.OrgID,
608
+		&i.Provider,
609
+		&i.StripeCustomerID,
610
+		&i.StripeSubscriptionID,
611
+		&i.StripeSubscriptionItemID,
612
+		&i.Plan,
613
+		&i.SubscriptionStatus,
614
+		&i.BillableSeats,
615
+		&i.SeatSnapshotAt,
616
+		&i.CurrentPeriodStart,
617
+		&i.CurrentPeriodEnd,
618
+		&i.CancelAtPeriodEnd,
619
+		&i.TrialEnd,
620
+		&i.PastDueAt,
621
+		&i.CanceledAt,
622
+		&i.LockedAt,
623
+		&i.LockReason,
624
+		&i.GraceUntil,
625
+		&i.LastWebhookEventID,
626
+		&i.CreatedAt,
627
+		&i.UpdatedAt,
628
+	)
629
+	return i, err
630
+}
631
+
632
+const markWebhookEventFailed = `-- name: MarkWebhookEventFailed :one
633
+UPDATE billing_webhook_events
634
+   SET process_error = $2,
635
+       processing_attempts = processing_attempts + 1
636
+ WHERE provider = 'stripe'
637
+   AND provider_event_id = $1
638
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
639
+`
640
+
641
+type MarkWebhookEventFailedParams struct {
642
+	ProviderEventID string
643
+	ProcessError    string
644
+}
645
+
646
+func (q *Queries) MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkWebhookEventFailedParams) (BillingWebhookEvent, error) {
647
+	row := db.QueryRow(ctx, markWebhookEventFailed, arg.ProviderEventID, arg.ProcessError)
648
+	var i BillingWebhookEvent
649
+	err := row.Scan(
650
+		&i.ID,
651
+		&i.Provider,
652
+		&i.ProviderEventID,
653
+		&i.EventType,
654
+		&i.ApiVersion,
655
+		&i.Payload,
656
+		&i.ReceivedAt,
657
+		&i.ProcessedAt,
658
+		&i.ProcessError,
659
+		&i.ProcessingAttempts,
660
+	)
661
+	return i, err
662
+}
663
+
664
+const markWebhookEventProcessed = `-- name: MarkWebhookEventProcessed :one
665
+UPDATE billing_webhook_events
666
+   SET processed_at = now(),
667
+       process_error = '',
668
+       processing_attempts = processing_attempts + 1
669
+ WHERE provider = 'stripe'
670
+   AND provider_event_id = $1
671
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
672
+`
673
+
674
+func (q *Queries) MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error) {
675
+	row := db.QueryRow(ctx, markWebhookEventProcessed, providerEventID)
676
+	var i BillingWebhookEvent
677
+	err := row.Scan(
678
+		&i.ID,
679
+		&i.Provider,
680
+		&i.ProviderEventID,
681
+		&i.EventType,
682
+		&i.ApiVersion,
683
+		&i.Payload,
684
+		&i.ReceivedAt,
685
+		&i.ProcessedAt,
686
+		&i.ProcessError,
687
+		&i.ProcessingAttempts,
688
+	)
689
+	return i, err
690
+}
691
+
692
+const setStripeCustomer = `-- name: SetStripeCustomer :one
693
+INSERT INTO org_billing_states (org_id, provider, stripe_customer_id)
694
+VALUES ($1, 'stripe', $2)
695
+ON CONFLICT (org_id) DO UPDATE
696
+   SET stripe_customer_id = EXCLUDED.stripe_customer_id,
697
+       provider = 'stripe',
698
+       updated_at = now()
699
+RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
700
+`
701
+
702
+type SetStripeCustomerParams struct {
703
+	OrgID            int64
704
+	StripeCustomerID pgtype.Text
705
+}
706
+
707
+func (q *Queries) SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeCustomerParams) (OrgBillingState, error) {
708
+	row := db.QueryRow(ctx, setStripeCustomer, arg.OrgID, arg.StripeCustomerID)
709
+	var i OrgBillingState
710
+	err := row.Scan(
711
+		&i.OrgID,
712
+		&i.Provider,
713
+		&i.StripeCustomerID,
714
+		&i.StripeSubscriptionID,
715
+		&i.StripeSubscriptionItemID,
716
+		&i.Plan,
717
+		&i.SubscriptionStatus,
718
+		&i.BillableSeats,
719
+		&i.SeatSnapshotAt,
720
+		&i.CurrentPeriodStart,
721
+		&i.CurrentPeriodEnd,
722
+		&i.CancelAtPeriodEnd,
723
+		&i.TrialEnd,
724
+		&i.PastDueAt,
725
+		&i.CanceledAt,
726
+		&i.LockedAt,
727
+		&i.LockReason,
728
+		&i.GraceUntil,
729
+		&i.LastWebhookEventID,
730
+		&i.CreatedAt,
731
+		&i.UpdatedAt,
732
+	)
733
+	return i, err
734
+}
735
+
736
+const upsertInvoice = `-- name: UpsertInvoice :one
737
+
738
+INSERT INTO billing_invoices (
739
+    org_id,
740
+    provider,
741
+    stripe_invoice_id,
742
+    stripe_customer_id,
743
+    stripe_subscription_id,
744
+    status,
745
+    number,
746
+    currency,
747
+    amount_due_cents,
748
+    amount_paid_cents,
749
+    amount_remaining_cents,
750
+    hosted_invoice_url,
751
+    invoice_pdf_url,
752
+    period_start,
753
+    period_end,
754
+    due_at,
755
+    paid_at,
756
+    voided_at
757
+)
758
+VALUES (
759
+    $1::bigint,
760
+    'stripe',
761
+    $2::text,
762
+    $3::text,
763
+    $4::text,
764
+    $5::billing_invoice_status,
765
+    $6::text,
766
+    $7::text,
767
+    $8::bigint,
768
+    $9::bigint,
769
+    $10::bigint,
770
+    $11::text,
771
+    $12::text,
772
+    $13::timestamptz,
773
+    $14::timestamptz,
774
+    $15::timestamptz,
775
+    $16::timestamptz,
776
+    $17::timestamptz
777
+)
778
+ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
779
+   SET org_id = EXCLUDED.org_id,
780
+       stripe_customer_id = EXCLUDED.stripe_customer_id,
781
+       stripe_subscription_id = EXCLUDED.stripe_subscription_id,
782
+       status = EXCLUDED.status,
783
+       number = EXCLUDED.number,
784
+       currency = EXCLUDED.currency,
785
+       amount_due_cents = EXCLUDED.amount_due_cents,
786
+       amount_paid_cents = EXCLUDED.amount_paid_cents,
787
+       amount_remaining_cents = EXCLUDED.amount_remaining_cents,
788
+       hosted_invoice_url = EXCLUDED.hosted_invoice_url,
789
+       invoice_pdf_url = EXCLUDED.invoice_pdf_url,
790
+       period_start = EXCLUDED.period_start,
791
+       period_end = EXCLUDED.period_end,
792
+       due_at = EXCLUDED.due_at,
793
+       paid_at = EXCLUDED.paid_at,
794
+       voided_at = EXCLUDED.voided_at,
795
+       updated_at = now()
796
+RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at
797
+`
798
+
799
+type UpsertInvoiceParams struct {
800
+	OrgID                int64
801
+	StripeInvoiceID      string
802
+	StripeCustomerID     string
803
+	StripeSubscriptionID pgtype.Text
804
+	Status               BillingInvoiceStatus
805
+	Number               string
806
+	Currency             string
807
+	AmountDueCents       int64
808
+	AmountPaidCents      int64
809
+	AmountRemainingCents int64
810
+	HostedInvoiceUrl     string
811
+	InvoicePdfUrl        string
812
+	PeriodStart          pgtype.Timestamptz
813
+	PeriodEnd            pgtype.Timestamptz
814
+	DueAt                pgtype.Timestamptz
815
+	PaidAt               pgtype.Timestamptz
816
+	VoidedAt             pgtype.Timestamptz
817
+}
818
+
819
+// ─── billing_invoices ──────────────────────────────────────────────
820
+func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error) {
821
+	row := db.QueryRow(ctx, upsertInvoice,
822
+		arg.OrgID,
823
+		arg.StripeInvoiceID,
824
+		arg.StripeCustomerID,
825
+		arg.StripeSubscriptionID,
826
+		arg.Status,
827
+		arg.Number,
828
+		arg.Currency,
829
+		arg.AmountDueCents,
830
+		arg.AmountPaidCents,
831
+		arg.AmountRemainingCents,
832
+		arg.HostedInvoiceUrl,
833
+		arg.InvoicePdfUrl,
834
+		arg.PeriodStart,
835
+		arg.PeriodEnd,
836
+		arg.DueAt,
837
+		arg.PaidAt,
838
+		arg.VoidedAt,
839
+	)
840
+	var i BillingInvoice
841
+	err := row.Scan(
842
+		&i.ID,
843
+		&i.OrgID,
844
+		&i.Provider,
845
+		&i.StripeInvoiceID,
846
+		&i.StripeCustomerID,
847
+		&i.StripeSubscriptionID,
848
+		&i.Status,
849
+		&i.Number,
850
+		&i.Currency,
851
+		&i.AmountDueCents,
852
+		&i.AmountPaidCents,
853
+		&i.AmountRemainingCents,
854
+		&i.HostedInvoiceUrl,
855
+		&i.InvoicePdfUrl,
856
+		&i.PeriodStart,
857
+		&i.PeriodEnd,
858
+		&i.DueAt,
859
+		&i.PaidAt,
860
+		&i.VoidedAt,
861
+		&i.CreatedAt,
862
+		&i.UpdatedAt,
863
+	)
864
+	return i, err
865
+}
internal/billing/sqlc/db.goadded
25 lines changed — click to load
@@ -0,0 +1,25 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+
5
+package billingdb
6
+
7
+import (
8
+	"context"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgconn"
12
+)
13
+
14
+type DBTX interface {
15
+	Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
16
+	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
17
+	QueryRow(context.Context, string, ...interface{}) pgx.Row
18
+}
19
+
20
+func New() *Queries {
21
+	return &Queries{}
22
+}
23
+
24
+type Queries struct {
25
+}
internal/actions/sqlc/models.go → internal/billing/sqlc/models.gocopied (89% similarity)
276 lines changed — click to load
@@ -2,7 +2,7 @@
22
 // versions:
33
 //   sqlc v1.31.1
44
 
5
-package actionsdb
5
+package billingdb
66
 
77
 import (
88
 	"database/sql/driver"
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/billing/sqlc/querier.goadded
32 lines changed — click to load
@@ -0,0 +1,32 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+
5
+package billingdb
6
+
7
+import (
8
+	"context"
9
+)
10
+
11
+type Querier interface {
12
+	ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error)
13
+	ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error)
14
+	// ─── billing_seat_snapshots ────────────────────────────────────────
15
+	CreateSeatSnapshot(ctx context.Context, db DBTX, arg CreateSeatSnapshotParams) (CreateSeatSnapshotRow, error)
16
+	// ─── billing_webhook_events ────────────────────────────────────────
17
+	CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg CreateWebhookEventReceiptParams) (BillingWebhookEvent, error)
18
+	// SPDX-License-Identifier: AGPL-3.0-or-later
19
+	// ─── org_billing_states ────────────────────────────────────────────
20
+	GetOrgBillingState(ctx context.Context, db DBTX, orgID int64) (OrgBillingState, error)
21
+	ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error)
22
+	ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error)
23
+	MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error)
24
+	MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error)
25
+	MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkWebhookEventFailedParams) (BillingWebhookEvent, error)
26
+	MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
27
+	SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeCustomerParams) (OrgBillingState, error)
28
+	// ─── billing_invoices ──────────────────────────────────────────────
29
+	UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error)
30
+}
31
+
32
+var _ Querier = (*Queries)(nil)
internal/checks/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/infra/config/config.gomodified
56 lines changed — click to load
@@ -38,6 +38,26 @@ type Config struct {
3838
 	Storage        StorageConfig        `toml:"storage"`
3939
 	Auth           AuthConfig           `toml:"auth"`
4040
 	Notif          NotifConfig          `toml:"notif"`
41
+	RateLimit      RateLimitConfig      `toml:"ratelimit"`
42
+}
43
+
44
+// RateLimitConfig configures runtime rate-limit budgets for surfaces that
45
+// don't carry a domain-specific limiter. The /api/v1/ JSON surface uses
46
+// the API.* sub-block; future surfaces (search, git transports) get their
47
+// own sub-blocks here as they're factored out of their handlers.
48
+type RateLimitConfig struct {
49
+	API APIRateLimitConfig `toml:"api"`
50
+}
51
+
52
+// APIRateLimitConfig sets the per-hour budgets for /api/v1/* requests.
53
+// AuthedPerHour applies when the caller presents a valid PAT (keyed by
54
+// token id). AnonPerHour applies to unauthenticated callers (keyed by
55
+// remote IP). Defaults are GitHub-aligned: 5000/h authed, 60/h anon —
56
+// operators tune via SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR /
57
+// SHITHUB_RATELIMIT__API__ANON_PER_HOUR.
58
+type APIRateLimitConfig struct {
59
+	AuthedPerHour int `toml:"authed_per_hour"`
60
+	AnonPerHour   int `toml:"anon_per_hour"`
4161
 }
4262
 
4363
 // NotifConfig configures the S29 notification surface. UnsubscribeKeyB64
@@ -205,6 +225,12 @@ func Defaults() Config {
205225
 				ForcePathStyle: true,
206226
 			},
207227
 		},
228
+		RateLimit: RateLimitConfig{
229
+			API: APIRateLimitConfig{
230
+				AuthedPerHour: 5000,
231
+				AnonPerHour:   60,
232
+			},
233
+		},
208234
 		Auth: AuthConfig{
209235
 			RequireEmailVerification: true,
210236
 			BaseURL:                  "http://127.0.0.1:8080",
@@ -321,6 +347,18 @@ func Validate(c *Config) error {
321347
 	if c.Auth.EmailFrom == "" {
322348
 		return errors.New("config: auth.email_from is required")
323349
 	}
350
+	if c.RateLimit.API.AuthedPerHour < 0 {
351
+		return fmt.Errorf("config: ratelimit.api.authed_per_hour: must be >= 0, got %d", c.RateLimit.API.AuthedPerHour)
352
+	}
353
+	if c.RateLimit.API.AnonPerHour < 0 {
354
+		return fmt.Errorf("config: ratelimit.api.anon_per_hour: must be >= 0, got %d", c.RateLimit.API.AnonPerHour)
355
+	}
356
+	if c.RateLimit.API.AuthedPerHour == 0 {
357
+		c.RateLimit.API.AuthedPerHour = 5000
358
+	}
359
+	if c.RateLimit.API.AnonPerHour == 0 {
360
+		c.RateLimit.API.AnonPerHour = 60
361
+	}
324362
 	return nil
325363
 }
326364
 
internal/infra/config/config_test.gomodified
62 lines changed — click to load
@@ -108,3 +108,62 @@ func TestMergeFlags_OverridesEnv(t *testing.T) {
108108
 		t.Errorf("Web.Addr: got %q, want :7777", cfg.Web.Addr)
109109
 	}
110110
 }
111
+
112
+func TestDefaults_RateLimitAPI(t *testing.T) {
113
+	t.Parallel()
114
+	cfg := Defaults()
115
+	if cfg.RateLimit.API.AuthedPerHour != 5000 {
116
+		t.Errorf("RateLimit.API.AuthedPerHour: got %d, want 5000", cfg.RateLimit.API.AuthedPerHour)
117
+	}
118
+	if cfg.RateLimit.API.AnonPerHour != 60 {
119
+		t.Errorf("RateLimit.API.AnonPerHour: got %d, want 60", cfg.RateLimit.API.AnonPerHour)
120
+	}
121
+}
122
+
123
+func TestValidate_RejectsNegativeRateLimit(t *testing.T) {
124
+	t.Parallel()
125
+	cfg := Defaults()
126
+	cfg.RateLimit.API.AuthedPerHour = -1
127
+	if err := Validate(&cfg); err == nil {
128
+		t.Errorf("expected validation error for ratelimit.api.authed_per_hour=-1")
129
+	}
130
+	cfg = Defaults()
131
+	cfg.RateLimit.API.AnonPerHour = -5
132
+	if err := Validate(&cfg); err == nil {
133
+		t.Errorf("expected validation error for ratelimit.api.anon_per_hour=-5")
134
+	}
135
+}
136
+
137
+func TestValidate_RateLimitZeroFillsDefault(t *testing.T) {
138
+	t.Parallel()
139
+	cfg := Defaults()
140
+	cfg.RateLimit.API.AuthedPerHour = 0
141
+	cfg.RateLimit.API.AnonPerHour = 0
142
+	if err := Validate(&cfg); err != nil {
143
+		t.Fatalf("Validate: %v", err)
144
+	}
145
+	if cfg.RateLimit.API.AuthedPerHour != 5000 {
146
+		t.Errorf("zero-fill authed: got %d, want 5000", cfg.RateLimit.API.AuthedPerHour)
147
+	}
148
+	if cfg.RateLimit.API.AnonPerHour != 60 {
149
+		t.Errorf("zero-fill anon: got %d, want 60", cfg.RateLimit.API.AnonPerHour)
150
+	}
151
+}
152
+
153
+func TestMergeEnv_RateLimitAPI(t *testing.T) {
154
+	t.Parallel()
155
+	cfg := Defaults()
156
+	env := []string{
157
+		"SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=10000",
158
+		"SHITHUB_RATELIMIT__API__ANON_PER_HOUR=120",
159
+	}
160
+	if err := mergeEnv(&cfg, env); err != nil {
161
+		t.Fatalf("mergeEnv: %v", err)
162
+	}
163
+	if cfg.RateLimit.API.AuthedPerHour != 10000 {
164
+		t.Errorf("AuthedPerHour: got %d, want 10000", cfg.RateLimit.API.AuthedPerHour)
165
+	}
166
+	if cfg.RateLimit.API.AnonPerHour != 120 {
167
+		t.Errorf("AnonPerHour: got %d, want 120", cfg.RateLimit.API.AnonPerHour)
168
+	}
169
+}
internal/issues/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/meta/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/migrationsfs/migrations/0061_billing_domain.sqladded
252 lines changed — click to load
@@ -0,0 +1,252 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- PAYMENTS SP02 — local billing domain.
4
+--
5
+-- Stripe remains the payment source of truth, but shithub needs local
6
+-- state for entitlement checks, UI summaries, idempotent webhook
7
+-- processing, and seat snapshots. These tables deliberately store
8
+-- provider IDs and invoice metadata only; card data never touches
9
+-- shithub.
10
+
11
+-- +goose Up
12
+
13
+CREATE TYPE billing_provider AS ENUM ('stripe');
14
+
15
+CREATE TYPE billing_subscription_status AS ENUM (
16
+    'none',
17
+    'incomplete',
18
+    'trialing',
19
+    'active',
20
+    'past_due',
21
+    'canceled',
22
+    'unpaid',
23
+    'paused'
24
+);
25
+
26
+CREATE TYPE billing_lock_reason AS ENUM (
27
+    'past_due',
28
+    'canceled',
29
+    'unpaid',
30
+    'manual'
31
+);
32
+
33
+CREATE TYPE billing_invoice_status AS ENUM (
34
+    'draft',
35
+    'open',
36
+    'paid',
37
+    'void',
38
+    'uncollectible'
39
+);
40
+
41
+CREATE TABLE org_billing_states (
42
+    org_id                      bigint                       PRIMARY KEY REFERENCES orgs(id) ON DELETE CASCADE,
43
+    provider                    billing_provider             NOT NULL DEFAULT 'stripe',
44
+    stripe_customer_id          text,
45
+    stripe_subscription_id      text,
46
+    stripe_subscription_item_id text,
47
+    plan                        org_plan                     NOT NULL DEFAULT 'free',
48
+    subscription_status         billing_subscription_status   NOT NULL DEFAULT 'none',
49
+    billable_seats              integer                      NOT NULL DEFAULT 0,
50
+    seat_snapshot_at            timestamptz,
51
+    current_period_start        timestamptz,
52
+    current_period_end          timestamptz,
53
+    cancel_at_period_end        boolean                      NOT NULL DEFAULT false,
54
+    trial_end                   timestamptz,
55
+    past_due_at                 timestamptz,
56
+    canceled_at                 timestamptz,
57
+    locked_at                   timestamptz,
58
+    lock_reason                 billing_lock_reason,
59
+    grace_until                 timestamptz,
60
+    last_webhook_event_id       text                          NOT NULL DEFAULT '',
61
+    created_at                  timestamptz                   NOT NULL DEFAULT now(),
62
+    updated_at                  timestamptz                   NOT NULL DEFAULT now(),
63
+
64
+    CONSTRAINT org_billing_states_seats_nonnegative CHECK (billable_seats >= 0),
65
+    CONSTRAINT org_billing_states_customer_id_not_blank CHECK (
66
+        stripe_customer_id IS NULL OR char_length(stripe_customer_id) > 0
67
+    ),
68
+    CONSTRAINT org_billing_states_subscription_id_not_blank CHECK (
69
+        stripe_subscription_id IS NULL OR char_length(stripe_subscription_id) > 0
70
+    ),
71
+    CONSTRAINT org_billing_states_subscription_item_id_not_blank CHECK (
72
+        stripe_subscription_item_id IS NULL OR char_length(stripe_subscription_item_id) > 0
73
+    ),
74
+    CONSTRAINT org_billing_states_lock_reason_requires_locked CHECK (
75
+        lock_reason IS NULL OR locked_at IS NOT NULL
76
+    ),
77
+    CONSTRAINT org_billing_states_grace_requires_locked CHECK (
78
+        grace_until IS NULL OR locked_at IS NOT NULL
79
+    ),
80
+    CONSTRAINT org_billing_states_period_order CHECK (
81
+        current_period_start IS NULL
82
+     OR current_period_end IS NULL
83
+     OR current_period_start <= current_period_end
84
+    )
85
+);
86
+
87
+CREATE UNIQUE INDEX org_billing_states_stripe_customer_unique
88
+    ON org_billing_states (stripe_customer_id)
89
+    WHERE stripe_customer_id IS NOT NULL;
90
+
91
+CREATE UNIQUE INDEX org_billing_states_stripe_subscription_unique
92
+    ON org_billing_states (stripe_subscription_id)
93
+    WHERE stripe_subscription_id IS NOT NULL;
94
+
95
+CREATE UNIQUE INDEX org_billing_states_stripe_subscription_item_unique
96
+    ON org_billing_states (stripe_subscription_item_id)
97
+    WHERE stripe_subscription_item_id IS NOT NULL;
98
+
99
+CREATE INDEX org_billing_states_status_idx
100
+    ON org_billing_states (subscription_status, updated_at DESC);
101
+
102
+CREATE INDEX org_billing_states_locked_idx
103
+    ON org_billing_states (locked_at)
104
+    WHERE locked_at IS NOT NULL;
105
+
106
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON org_billing_states
107
+    FOR EACH ROW EXECUTE FUNCTION tg_set_updated_at();
108
+
109
+-- Backfill current organizations as Free. orgs.plan remains the
110
+-- human-facing summary; billing state starts conservative until Stripe
111
+-- webhooks activate a paid subscription.
112
+INSERT INTO org_billing_states (org_id, plan)
113
+SELECT id, 'free'::org_plan
114
+FROM orgs
115
+ON CONFLICT (org_id) DO NOTHING;
116
+
117
+-- +goose StatementBegin
118
+CREATE OR REPLACE FUNCTION tg_org_billing_state_seed() RETURNS trigger AS $$
119
+BEGIN
120
+    INSERT INTO org_billing_states (org_id, plan)
121
+    VALUES (NEW.id, 'free'::org_plan)
122
+    ON CONFLICT (org_id) DO NOTHING;
123
+    RETURN NEW;
124
+END;
125
+$$ LANGUAGE plpgsql;
126
+-- +goose StatementEnd
127
+
128
+CREATE TRIGGER tg_org_billing_state_seed_ai
129
+    AFTER INSERT ON orgs
130
+    FOR EACH ROW EXECUTE FUNCTION tg_org_billing_state_seed();
131
+
132
+CREATE TABLE billing_seat_snapshots (
133
+    id                         bigserial          PRIMARY KEY,
134
+    org_id                     bigint             NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
135
+    provider                   billing_provider   NOT NULL DEFAULT 'stripe',
136
+    stripe_subscription_id     text,
137
+    active_members             integer            NOT NULL,
138
+    billable_seats             integer            NOT NULL,
139
+    source                     text               NOT NULL DEFAULT 'local',
140
+    captured_at                timestamptz        NOT NULL DEFAULT now(),
141
+
142
+    CONSTRAINT billing_seat_snapshots_active_members_nonnegative CHECK (active_members >= 0),
143
+    CONSTRAINT billing_seat_snapshots_billable_seats_nonnegative CHECK (billable_seats >= 0),
144
+    CONSTRAINT billing_seat_snapshots_source_length CHECK (char_length(source) BETWEEN 1 AND 64)
145
+);
146
+
147
+CREATE INDEX billing_seat_snapshots_org_captured_idx
148
+    ON billing_seat_snapshots (org_id, captured_at DESC);
149
+
150
+CREATE TABLE billing_invoices (
151
+    id                         bigserial               PRIMARY KEY,
152
+    org_id                     bigint                  NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
153
+    provider                   billing_provider        NOT NULL DEFAULT 'stripe',
154
+    stripe_invoice_id          text                    NOT NULL,
155
+    stripe_customer_id         text                    NOT NULL,
156
+    stripe_subscription_id     text,
157
+    status                     billing_invoice_status  NOT NULL,
158
+    number                     text                    NOT NULL DEFAULT '',
159
+    currency                   text                    NOT NULL,
160
+    amount_due_cents           bigint                  NOT NULL DEFAULT 0,
161
+    amount_paid_cents          bigint                  NOT NULL DEFAULT 0,
162
+    amount_remaining_cents     bigint                  NOT NULL DEFAULT 0,
163
+    hosted_invoice_url         text                    NOT NULL DEFAULT '',
164
+    invoice_pdf_url            text                    NOT NULL DEFAULT '',
165
+    period_start               timestamptz,
166
+    period_end                 timestamptz,
167
+    due_at                     timestamptz,
168
+    paid_at                    timestamptz,
169
+    voided_at                  timestamptz,
170
+    created_at                 timestamptz             NOT NULL DEFAULT now(),
171
+    updated_at                 timestamptz             NOT NULL DEFAULT now(),
172
+
173
+    CONSTRAINT billing_invoices_stripe_invoice_not_blank CHECK (char_length(stripe_invoice_id) > 0),
174
+    CONSTRAINT billing_invoices_stripe_customer_not_blank CHECK (char_length(stripe_customer_id) > 0),
175
+    CONSTRAINT billing_invoices_currency_iso CHECK (
176
+        char_length(currency) = 3 AND currency = lower(currency)
177
+    ),
178
+    CONSTRAINT billing_invoices_amounts_nonnegative CHECK (
179
+        amount_due_cents >= 0
180
+        AND amount_paid_cents >= 0
181
+        AND amount_remaining_cents >= 0
182
+    ),
183
+    CONSTRAINT billing_invoices_period_order CHECK (
184
+        period_start IS NULL OR period_end IS NULL OR period_start <= period_end
185
+    ),
186
+
187
+    UNIQUE (provider, stripe_invoice_id)
188
+);
189
+
190
+CREATE INDEX billing_invoices_org_created_idx
191
+    ON billing_invoices (org_id, created_at DESC);
192
+
193
+CREATE INDEX billing_invoices_status_idx
194
+    ON billing_invoices (status, created_at DESC);
195
+
196
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON billing_invoices
197
+    FOR EACH ROW EXECUTE FUNCTION tg_set_updated_at();
198
+
199
+CREATE TABLE billing_webhook_events (
200
+    id                         bigserial          PRIMARY KEY,
201
+    provider                   billing_provider   NOT NULL DEFAULT 'stripe',
202
+    provider_event_id          text               NOT NULL,
203
+    event_type                 text               NOT NULL,
204
+    api_version                text               NOT NULL DEFAULT '',
205
+    payload                    jsonb              NOT NULL DEFAULT '{}'::jsonb,
206
+    received_at                timestamptz        NOT NULL DEFAULT now(),
207
+    processed_at               timestamptz,
208
+    process_error              text               NOT NULL DEFAULT '',
209
+    processing_attempts        integer            NOT NULL DEFAULT 0,
210
+
211
+    CONSTRAINT billing_webhook_events_provider_event_not_blank CHECK (char_length(provider_event_id) > 0),
212
+    CONSTRAINT billing_webhook_events_type_not_blank CHECK (char_length(event_type) > 0),
213
+    CONSTRAINT billing_webhook_events_attempts_nonnegative CHECK (processing_attempts >= 0),
214
+    CONSTRAINT billing_webhook_events_payload_object CHECK (jsonb_typeof(payload) = 'object'),
215
+
216
+    UNIQUE (provider, provider_event_id)
217
+);
218
+
219
+CREATE INDEX billing_webhook_events_received_idx
220
+    ON billing_webhook_events (received_at DESC);
221
+
222
+CREATE INDEX billing_webhook_events_processed_idx
223
+    ON billing_webhook_events (processed_at)
224
+    WHERE processed_at IS NULL;
225
+
226
+-- +goose Down
227
+DROP INDEX IF EXISTS billing_webhook_events_processed_idx;
228
+DROP INDEX IF EXISTS billing_webhook_events_received_idx;
229
+DROP TABLE IF EXISTS billing_webhook_events;
230
+
231
+DROP TRIGGER IF EXISTS set_updated_at ON billing_invoices;
232
+DROP INDEX IF EXISTS billing_invoices_status_idx;
233
+DROP INDEX IF EXISTS billing_invoices_org_created_idx;
234
+DROP TABLE IF EXISTS billing_invoices;
235
+
236
+DROP INDEX IF EXISTS billing_seat_snapshots_org_captured_idx;
237
+DROP TABLE IF EXISTS billing_seat_snapshots;
238
+
239
+DROP TRIGGER IF EXISTS tg_org_billing_state_seed_ai ON orgs;
240
+DROP FUNCTION IF EXISTS tg_org_billing_state_seed();
241
+DROP TRIGGER IF EXISTS set_updated_at ON org_billing_states;
242
+DROP INDEX IF EXISTS org_billing_states_locked_idx;
243
+DROP INDEX IF EXISTS org_billing_states_status_idx;
244
+DROP INDEX IF EXISTS org_billing_states_stripe_subscription_item_unique;
245
+DROP INDEX IF EXISTS org_billing_states_stripe_subscription_unique;
246
+DROP INDEX IF EXISTS org_billing_states_stripe_customer_unique;
247
+DROP TABLE IF EXISTS org_billing_states;
248
+
249
+DROP TYPE IF EXISTS billing_invoice_status;
250
+DROP TYPE IF EXISTS billing_lock_reason;
251
+DROP TYPE IF EXISTS billing_subscription_status;
252
+DROP TYPE IF EXISTS billing_provider;
internal/notif/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/orgs/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/pulls/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/ratelimit/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/repos/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/social/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/users/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/web/auth_wiring.gomodified
19 lines changed — click to load
@@ -28,6 +28,7 @@ import (
2828
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
2929
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
3030
 	apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
31
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
3132
 	authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
3233
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
3334
 	"github.com/tenseleyFlow/shithub/internal/web/render"
@@ -68,6 +69,12 @@ func buildAPIHandlers(
6869
 		RunnerJWT:   runnerJWT,
6970
 		SecretBox:   secretBox,
7071
 		RateLimiter: rateLimiter,
72
+		BaseURL:     cfg.Auth.BaseURL,
73
+		APILimit: apilimit.Config{
74
+			AuthedPerHour: cfg.RateLimit.API.AuthedPerHour,
75
+			AnonPerHour:   cfg.RateLimit.API.AnonPerHour,
76
+			Logger:        logger,
77
+		},
7178
 	})
7279
 }
7380
 
internal/web/embed_test.gomodified
34 lines changed — click to load
@@ -120,6 +120,34 @@ func TestOrgPagesRenderSingleSharedOrgNav(t *testing.T) {
120120
 	}
121121
 }
122122
 
123
+func TestExploreFeedFragmentAppendsRowsAndReplacesPagination(t *testing.T) {
124
+	t.Parallel()
125
+	r, err := render.New(TemplatesFS(), render.Options{})
126
+	if err != nil {
127
+		t.Fatalf("render.New: %v", err)
128
+	}
129
+	rw := httptest.NewRecorder()
130
+	if err := r.RenderFragment(rw, "explore/feed_page", map[string]any{
131
+		"FeedHasNext": true,
132
+		"FeedNextURL": "/explore?before=2026-05-12T03%3A00%3A00Z~42",
133
+	}); err != nil {
134
+		t.Fatalf("RenderFragment: %v", err)
135
+	}
136
+	body := rw.Body.String()
137
+	for _, want := range []string{
138
+		`id="shithub-feed-fragment-rows"`,
139
+		`hx-target="#shithub-feed-list"`,
140
+		`hx-swap="beforeend"`,
141
+		`hx-select="#shithub-feed-fragment-rows > *"`,
142
+		`hx-select-oob="#shithub-feed-pagination:outerHTML"`,
143
+		`Loading...`,
144
+	} {
145
+		if !strings.Contains(body, want) {
146
+			t.Fatalf("fragment missing %q in:\n%s", want, body)
147
+		}
148
+	}
149
+}
150
+
123151
 // errorOriginatesInPartial returns true when an html/template execute
124152
 // error blames a file whose basename starts with `_`. Errors from such
125153
 // files are bugs in the partial because we render with an empty map
internal/web/handlers/api/actions_cancel.gomodified
23 lines changed — click to load
@@ -39,7 +39,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) {
3939
 		writeAPIError(w, http.StatusNotFound, "job not found")
4040
 		return
4141
 	}
42
-	job, run, repo, ok := h.resolveCancellableJob(w, r, auth.UserID, jobID)
42
+	job, run, repo, ok := h.resolveCancellableJob(w, r, auth.PolicyActor(), jobID)
4343
 	if !ok {
4444
 		return
4545
 	}
@@ -65,7 +65,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) {
6565
 func (h *Handlers) resolveCancellableJob(
6666
 	w http.ResponseWriter,
6767
 	r *http.Request,
68
-	userID int64,
68
+	actor policy.Actor,
6969
 	jobID int64,
7070
 ) (actionsdb.WorkflowJob, actionsdb.WorkflowRun, reposdb.Repo, bool) {
7171
 	q := actionsdb.New()
@@ -92,7 +92,6 @@ func (h *Handlers) resolveCancellableJob(
9292
 		}
9393
 		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
9494
 	}
95
-	actor := policy.UserActor(userID, "", false, false)
9695
 	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
9796
 		writeAPIError(w, http.StatusNotFound, "job not found")
9897
 		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
internal/web/handlers/api/actions_rerun.gomodified
23 lines changed — click to load
@@ -28,7 +28,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) {
2828
 		writeAPIError(w, http.StatusNotFound, "run not found")
2929
 		return
3030
 	}
31
-	run, repo, ok := h.resolveLifecycleRun(w, r, auth.UserID, runID)
31
+	run, repo, ok := h.resolveLifecycleRun(w, r, auth.PolicyActor(), runID)
3232
 	if !ok {
3333
 		return
3434
 	}
@@ -54,7 +54,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) {
5454
 func (h *Handlers) resolveLifecycleRun(
5555
 	w http.ResponseWriter,
5656
 	r *http.Request,
57
-	userID int64,
57
+	actor policy.Actor,
5858
 	runID int64,
5959
 ) (actionsdb.WorkflowRun, reposdb.Repo, bool) {
6060
 	q := actionsdb.New()
@@ -72,7 +72,6 @@ func (h *Handlers) resolveLifecycleRun(
7272
 		}
7373
 		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
7474
 	}
75
-	actor := policy.UserActor(userID, "", false, false)
7675
 	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
7776
 		writeAPIError(w, http.StatusNotFound, "run not found")
7877
 		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
internal/web/handlers/api/api.gomodified
48 lines changed — click to load
@@ -24,6 +24,7 @@ import (
2424
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2525
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
2626
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
27
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
2728
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2829
 )
2930
 
@@ -38,6 +39,12 @@ type Deps struct {
3839
 	RunnerJWT   *runnerjwt.Signer
3940
 	SecretBox   *secretbox.Box
4041
 	RateLimiter *ratelimit.Limiter
42
+	// BaseURL is the public scheme://host prefix used for absolute
43
+	// pagination Link headers. Empty falls back to path-relative URLs.
44
+	BaseURL string
45
+	// APILimit configures the /api/v1/* rate-limit middleware. Zero
46
+	// values inherit apilimit.Middleware's no-op fallback.
47
+	APILimit apilimit.Config
4148
 }
4249
 
4350
 // Handlers is the registered API handler set. Construct with New.
@@ -77,9 +84,20 @@ const runnerAPIMaxBodyBytes = 768 * 1024
7784
 
7885
 // Mount registers /api/v1/* on r. Caller is responsible for putting r
7986
 // in a CSRF-exempt group.
87
+//
88
+// Outer middleware on every /api/v1/* request: apilimit stamps the
89
+// X-RateLimit-* headers and refuses over-budget callers with a JSON
90
+// 429. Inner groups attach body caps, PAT auth, and scope decorators
91
+// according to the surface they expose.
8092
 func (h *Handlers) Mount(r chi.Router) {
93
+	apiLimitMW := apilimit.Middleware(h.d.RateLimiter, apilimit.Config{
94
+		AuthedPerHour: h.d.APILimit.AuthedPerHour,
95
+		AnonPerHour:   h.d.APILimit.AnonPerHour,
96
+		Logger:        h.d.Logger,
97
+	})
8198
 	r.Group(func(r chi.Router) {
8299
 		r.Use(middleware.MaxBodySize(runnerAPIMaxBodyBytes))
100
+		r.Use(apiLimitMW)
83101
 		h.mountRunners(r)
84102
 	})
85103
 	r.Group(func(r chi.Router) {
@@ -88,6 +106,9 @@ func (h *Handlers) Mount(r chi.Router) {
88106
 			Pool:      h.d.Pool,
89107
 			Debouncer: h.d.Debouncer,
90108
 		}))
109
+		r.Use(apiLimitMW)
110
+		// /meta is capability discovery — no scope required, anon ok.
111
+		h.mountMeta(r)
91112
 		r.Group(func(r chi.Router) {
92113
 			r.Use(middleware.RequireScope(pat.ScopeUserRead))
93114
 			r.Get("/api/v1/user", h.userMe)
internal/web/handlers/api/apilimit/apilimit.goadded
105 lines changed — click to load
@@ -0,0 +1,105 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package apilimit is the per-request rate limiter that fronts /api/v1/.
4
+// It does two things on every request:
5
+//
6
+//  1. Buckets the caller: PAT-authenticated requests are keyed by token
7
+//     id (scope "api:authed"); anonymous requests are keyed by remote
8
+//     IP (scope "api:anon"). Budgets come from cfg.RateLimit.API.
9
+//  2. Stamps X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset
10
+//     on the response — even on success. The shithub-cli HTTP client
11
+//     parses these headers on every response to surface back-off hints
12
+//     (shithub-cli/internal/api/errors.go).
13
+//
14
+// On deny, the response is the canonical /api/v1 JSON error envelope
15
+// `{"error": "rate limit exceeded"}` with Retry-After set. Postgres
16
+// errors fail open (ratelimit.Allow's documented behavior); the request
17
+// proceeds with whatever decision we have and a warn-level log line.
18
+package apilimit
19
+
20
+import (
21
+	"log/slog"
22
+	"net/http"
23
+	"strconv"
24
+	"time"
25
+
26
+	"github.com/tenseleyFlow/shithub/internal/ratelimit"
27
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
28
+)
29
+
30
+// Config is the per-instance configuration for the middleware. Both
31
+// budgets are required to be positive at construction.
32
+type Config struct {
33
+	// AuthedPerHour is the bucket size for PAT-authenticated callers.
34
+	AuthedPerHour int
35
+	// AnonPerHour is the bucket size for unauthenticated callers.
36
+	AnonPerHour int
37
+	// Logger receives warn-level lines when the backing counter errors.
38
+	// nil disables logging.
39
+	Logger *slog.Logger
40
+}
41
+
42
+// Middleware returns a chi-compatible middleware that applies the
43
+// configured budgets and stamps the standard X-RateLimit-* headers.
44
+// When l is nil the middleware is a no-op (used by tests that don't
45
+// stand up the ratelimit DB).
46
+func Middleware(l *ratelimit.Limiter, cfg Config) func(http.Handler) http.Handler {
47
+	authedPolicy := ratelimit.Policy{
48
+		Scope:  "api:authed",
49
+		Max:    cfg.AuthedPerHour,
50
+		Window: time.Hour,
51
+	}
52
+	anonPolicy := ratelimit.Policy{
53
+		Scope:  "api:anon",
54
+		Max:    cfg.AnonPerHour,
55
+		Window: time.Hour,
56
+	}
57
+	logger := cfg.Logger
58
+	return func(next http.Handler) http.Handler {
59
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60
+			if l == nil {
61
+				next.ServeHTTP(w, r)
62
+				return
63
+			}
64
+			policy, key := pickBucket(r, authedPolicy, anonPolicy)
65
+			if policy.Max <= 0 || key == "" {
66
+				// Misconfigured budget or no key derivable — fail open
67
+				// rather than refuse service. The boot-time validation
68
+				// in config.Validate keeps this branch unreachable in
69
+				// practice.
70
+				next.ServeHTTP(w, r)
71
+				return
72
+			}
73
+			decision, err := l.Allow(r.Context(), policy, key)
74
+			if err != nil && logger != nil {
75
+				logger.WarnContext(r.Context(), "apilimit: counter error", "scope", policy.Scope, "key", key, "error", err)
76
+			}
77
+			ratelimit.StampHeaders(w, decision)
78
+			if !decision.Allowed {
79
+				retry := int(decision.RetryAfter / time.Second)
80
+				if retry < 1 {
81
+					retry = 1
82
+				}
83
+				w.Header().Set("Retry-After", strconv.Itoa(retry))
84
+				w.Header().Set("Content-Type", "application/json; charset=utf-8")
85
+				w.Header().Set("Cache-Control", "no-store")
86
+				w.WriteHeader(http.StatusTooManyRequests)
87
+				_, _ = w.Write([]byte(`{"error":"rate limit exceeded"}` + "\n"))
88
+				return
89
+			}
90
+			next.ServeHTTP(w, r)
91
+		})
92
+	}
93
+}
94
+
95
+func pickBucket(r *http.Request, authed, anon ratelimit.Policy) (ratelimit.Policy, string) {
96
+	auth := middleware.PATAuthFromContext(r.Context())
97
+	if auth.TokenID != 0 {
98
+		return authed, "pat:" + strconv.FormatInt(auth.TokenID, 10)
99
+	}
100
+	ip := middleware.RealIPFromContext(r.Context(), r)
101
+	if ip == "" {
102
+		return anon, ""
103
+	}
104
+	return anon, "ip:" + ip
105
+}
internal/web/handlers/api/apipage/page.goadded
171 lines changed — click to load
@@ -0,0 +1,171 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package apipage centralizes /api/v1 list-endpoint pagination concerns:
4
+// reading page / per_page query params with sensible defaults and clamps,
5
+// and emitting the canonical RFC 8288 Link header for cursor navigation.
6
+//
7
+// The Link header format matches GitHub's REST API verbatim so existing
8
+// gh-style clients (including shithub-cli/internal/api.ParseLinkHeader)
9
+// keep working. All emitted URLs are absolute when a baseURL is provided;
10
+// callers should pass their configured public base URL so links survive
11
+// reverse-proxying and host-rewriting.
12
+package apipage
13
+
14
+import (
15
+	"net/http"
16
+	"net/url"
17
+	"strconv"
18
+	"strings"
19
+)
20
+
21
+// DefaultPerPage is the per_page value used when the caller omits it.
22
+const DefaultPerPage = 30
23
+
24
+// MaxPerPage caps per_page to prevent unbounded list responses. Mirrors
25
+// GitHub REST's 100 ceiling so client expectations port directly.
26
+const MaxPerPage = 100
27
+
28
+// Page describes a paginated response state. Total >= 0 enables emitting
29
+// first/last in the Link header. Total == -1 disables them and falls
30
+// back to HasMore for forward-only pagination (used when totals are
31
+// expensive to compute and a "next" cursor is cheap).
32
+type Page struct {
33
+	Current int  // 1-indexed; must be >= 1
34
+	PerPage int  // > 0
35
+	Total   int  // total items across all pages; -1 when unknown
36
+	HasMore bool // honored only when Total < 0
37
+}
38
+
39
+// ParseQuery reads ?page= and ?per_page= from r.URL.Query() with
40
+// defaults page=1, per_page=defaultPerPage. per_page is clamped to
41
+// [1, maxPerPage]. Non-integer or negative values fall back to defaults
42
+// rather than 400 — matches gh/GitHub leniency for list endpoints.
43
+func ParseQuery(r *http.Request, defaultPerPage, maxPerPage int) (page, perPage int) {
44
+	if defaultPerPage <= 0 {
45
+		defaultPerPage = DefaultPerPage
46
+	}
47
+	if maxPerPage <= 0 {
48
+		maxPerPage = MaxPerPage
49
+	}
50
+	q := r.URL.Query()
51
+	page = atoiOr(q.Get("page"), 1)
52
+	if page < 1 {
53
+		page = 1
54
+	}
55
+	perPage = atoiOr(q.Get("per_page"), defaultPerPage)
56
+	if perPage < 1 {
57
+		perPage = defaultPerPage
58
+	}
59
+	if perPage > maxPerPage {
60
+		perPage = maxPerPage
61
+	}
62
+	return page, perPage
63
+}
64
+
65
+// LinkHeader returns the canonical Link header value for p. The header
66
+// is composed of up to four entries (first, prev, next, last) with
67
+// rel values quoted per RFC 8288.
68
+//
69
+// baseURL is the public scheme://host prefix (e.g. "https://shithub.sh");
70
+// when empty, links are emitted as path-relative URLs. reqURL is the
71
+// incoming request URL — its query string is preserved and only the
72
+// page parameter is rewritten per rel.
73
+//
74
+// Returns "" when there is no useful link to emit (single-page result
75
+// with no forward signal).
76
+func (p Page) LinkHeader(baseURL string, reqURL *url.URL) string {
77
+	if reqURL == nil || p.PerPage <= 0 {
78
+		return ""
79
+	}
80
+	cur := p.Current
81
+	if cur < 1 {
82
+		cur = 1
83
+	}
84
+
85
+	var lastPage int
86
+	knownTotal := p.Total >= 0
87
+	if knownTotal {
88
+		lastPage = lastPageFor(p.Total, p.PerPage)
89
+	}
90
+
91
+	var hasPrev, hasNext bool
92
+	switch {
93
+	case knownTotal:
94
+		hasPrev = cur > 1 && lastPage >= 1
95
+		hasNext = cur < lastPage
96
+	default:
97
+		hasPrev = cur > 1
98
+		hasNext = p.HasMore
99
+	}
100
+
101
+	if !hasPrev && !hasNext && !knownTotal {
102
+		return ""
103
+	}
104
+	if knownTotal && lastPage <= 1 {
105
+		return ""
106
+	}
107
+
108
+	prefix := strings.TrimRight(baseURL, "/")
109
+
110
+	var entries []string
111
+	if knownTotal {
112
+		entries = append(entries, formatLink(prefix, reqURL, 1, "first"))
113
+	}
114
+	if hasPrev {
115
+		entries = append(entries, formatLink(prefix, reqURL, cur-1, "prev"))
116
+	}
117
+	if hasNext {
118
+		entries = append(entries, formatLink(prefix, reqURL, cur+1, "next"))
119
+	}
120
+	if knownTotal && lastPage >= 1 {
121
+		entries = append(entries, formatLink(prefix, reqURL, lastPage, "last"))
122
+	}
123
+	return strings.Join(entries, ", ")
124
+}
125
+
126
+// lastPageFor returns the 1-indexed page count for a known total. Always
127
+// >= 1 so callers can render "first" / "last" even on an empty result.
128
+func lastPageFor(total, perPage int) int {
129
+	if total <= 0 {
130
+		return 1
131
+	}
132
+	pages := total / perPage
133
+	if total%perPage != 0 {
134
+		pages++
135
+	}
136
+	if pages < 1 {
137
+		pages = 1
138
+	}
139
+	return pages
140
+}
141
+
142
+func formatLink(prefix string, reqURL *url.URL, page int, rel string) string {
143
+	q := reqURL.Query()
144
+	q.Set("page", strconv.Itoa(page))
145
+	rebuilt := *reqURL
146
+	rebuilt.RawQuery = q.Encode()
147
+	rebuilt.Scheme = ""
148
+	rebuilt.Host = ""
149
+
150
+	var b strings.Builder
151
+	b.WriteByte('<')
152
+	if prefix != "" {
153
+		b.WriteString(prefix)
154
+	}
155
+	b.WriteString(rebuilt.RequestURI())
156
+	b.WriteString(`>; rel="`)
157
+	b.WriteString(rel)
158
+	b.WriteByte('"')
159
+	return b.String()
160
+}
161
+
162
+func atoiOr(s string, fallback int) int {
163
+	if s == "" {
164
+		return fallback
165
+	}
166
+	n, err := strconv.Atoi(s)
167
+	if err != nil {
168
+		return fallback
169
+	}
170
+	return n
171
+}
internal/web/handlers/api/apipage/page_test.goadded
256 lines changed — click to load
@@ -0,0 +1,256 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package apipage_test
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"net/url"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
12
+)
13
+
14
+func TestParseQuery_Defaults(t *testing.T) {
15
+	t.Parallel()
16
+	r := httptest.NewRequest("GET", "/api/v1/things", nil)
17
+	page, perPage := apipage.ParseQuery(r, 0, 0)
18
+	if page != 1 || perPage != apipage.DefaultPerPage {
19
+		t.Fatalf("got page=%d, perPage=%d; want 1/%d", page, perPage, apipage.DefaultPerPage)
20
+	}
21
+}
22
+
23
+func TestParseQuery_ClampsPerPage(t *testing.T) {
24
+	t.Parallel()
25
+	r := httptest.NewRequest("GET", "/api/v1/things?page=2&per_page=500", nil)
26
+	page, perPage := apipage.ParseQuery(r, 30, 100)
27
+	if page != 2 {
28
+		t.Errorf("page: got %d, want 2", page)
29
+	}
30
+	if perPage != 100 {
31
+		t.Errorf("per_page: got %d, want 100", perPage)
32
+	}
33
+}
34
+
35
+func TestParseQuery_NegativeFallback(t *testing.T) {
36
+	t.Parallel()
37
+	r := httptest.NewRequest("GET", "/api/v1/things?page=-3&per_page=-1", nil)
38
+	page, perPage := apipage.ParseQuery(r, 0, 0)
39
+	if page != 1 || perPage != apipage.DefaultPerPage {
40
+		t.Fatalf("got page=%d, perPage=%d; want 1/%d", page, perPage, apipage.DefaultPerPage)
41
+	}
42
+}
43
+
44
+func TestParseQuery_NonInteger(t *testing.T) {
45
+	t.Parallel()
46
+	r := httptest.NewRequest("GET", "/api/v1/things?page=banana&per_page=cherry", nil)
47
+	page, perPage := apipage.ParseQuery(r, 25, 50)
48
+	if page != 1 || perPage != 25 {
49
+		t.Fatalf("got page=%d, perPage=%d; want 1/25", page, perPage)
50
+	}
51
+}
52
+
53
+func TestLinkHeader_MiddlePage(t *testing.T) {
54
+	t.Parallel()
55
+	u, _ := url.Parse("/api/v1/user/starred?per_page=30&page=2")
56
+	p := apipage.Page{Current: 2, PerPage: 30, Total: 120}
57
+	h := p.LinkHeader("https://shithub.sh", u)
58
+
59
+	links := parseLink(h)
60
+	wantRels := []string{"first", "prev", "next", "last"}
61
+	for _, rel := range wantRels {
62
+		if _, ok := links[rel]; !ok {
63
+			t.Errorf("missing rel=%q in %s", rel, h)
64
+		}
65
+	}
66
+	if got := pageFor(links["next"]); got != "3" {
67
+		t.Errorf("next page: got %s, want 3", got)
68
+	}
69
+	if got := pageFor(links["prev"]); got != "1" {
70
+		t.Errorf("prev page: got %s, want 1", got)
71
+	}
72
+	if got := pageFor(links["last"]); got != "4" {
73
+		t.Errorf("last page: got %s, want 4", got)
74
+	}
75
+	if got := pageFor(links["first"]); got != "1" {
76
+		t.Errorf("first page: got %s, want 1", got)
77
+	}
78
+	if !strings.HasPrefix(links["next"], "https://shithub.sh/api/v1/user/starred?") {
79
+		t.Errorf("next link not absolute: %s", links["next"])
80
+	}
81
+}
82
+
83
+func TestLinkHeader_FirstPage(t *testing.T) {
84
+	t.Parallel()
85
+	u, _ := url.Parse("/api/v1/repos?per_page=10&page=1")
86
+	p := apipage.Page{Current: 1, PerPage: 10, Total: 25}
87
+	links := parseLink(p.LinkHeader("https://shithub.sh", u))
88
+	if _, ok := links["prev"]; ok {
89
+		t.Errorf("prev should be absent on first page; got %v", links)
90
+	}
91
+	if _, ok := links["next"]; !ok {
92
+		t.Error("next should be present on first page")
93
+	}
94
+	if got := pageFor(links["last"]); got != "3" {
95
+		t.Errorf("last page: got %s, want 3", got)
96
+	}
97
+}
98
+
99
+func TestLinkHeader_LastPage(t *testing.T) {
100
+	t.Parallel()
101
+	u, _ := url.Parse("/api/v1/repos?per_page=10&page=3")
102
+	p := apipage.Page{Current: 3, PerPage: 10, Total: 25}
103
+	links := parseLink(p.LinkHeader("https://shithub.sh", u))
104
+	if _, ok := links["next"]; ok {
105
+		t.Errorf("next should be absent on last page; got %v", links)
106
+	}
107
+	if _, ok := links["prev"]; !ok {
108
+		t.Error("prev should be present on last page")
109
+	}
110
+}
111
+
112
+func TestLinkHeader_SinglePage(t *testing.T) {
113
+	t.Parallel()
114
+	u, _ := url.Parse("/api/v1/repos?per_page=30&page=1")
115
+	p := apipage.Page{Current: 1, PerPage: 30, Total: 5}
116
+	if got := p.LinkHeader("https://shithub.sh", u); got != "" {
117
+		t.Errorf("expected empty header for single-page result; got %q", got)
118
+	}
119
+}
120
+
121
+func TestLinkHeader_StreamForm(t *testing.T) {
122
+	t.Parallel()
123
+	u, _ := url.Parse("/api/v1/feed?page=2")
124
+	p := apipage.Page{Current: 2, PerPage: 30, Total: -1, HasMore: true}
125
+	links := parseLink(p.LinkHeader("https://shithub.sh", u))
126
+	if _, ok := links["first"]; ok {
127
+		t.Error("first should not appear when total unknown")
128
+	}
129
+	if _, ok := links["last"]; ok {
130
+		t.Error("last should not appear when total unknown")
131
+	}
132
+	if _, ok := links["next"]; !ok {
133
+		t.Error("next should appear when HasMore=true")
134
+	}
135
+	if _, ok := links["prev"]; !ok {
136
+		t.Error("prev should appear when Current > 1")
137
+	}
138
+}
139
+
140
+func TestLinkHeader_StreamFormExhausted(t *testing.T) {
141
+	t.Parallel()
142
+	u, _ := url.Parse("/api/v1/feed?page=4")
143
+	p := apipage.Page{Current: 4, PerPage: 30, Total: -1, HasMore: false}
144
+	links := parseLink(p.LinkHeader("https://shithub.sh", u))
145
+	if _, ok := links["next"]; ok {
146
+		t.Error("next should be absent when HasMore=false")
147
+	}
148
+	if _, ok := links["prev"]; !ok {
149
+		t.Error("prev should still appear when Current > 1")
150
+	}
151
+}
152
+
153
+func TestLinkHeader_PreservesOtherQueryParams(t *testing.T) {
154
+	t.Parallel()
155
+	u, _ := url.Parse("/api/v1/issues?state=open&labels=bug,ux&per_page=30&page=2")
156
+	p := apipage.Page{Current: 2, PerPage: 30, Total: 120}
157
+	links := parseLink(p.LinkHeader("https://shithub.sh", u))
158
+	for rel, link := range links {
159
+		if !strings.Contains(link, "state=open") {
160
+			t.Errorf("rel=%s lost state=open: %s", rel, link)
161
+		}
162
+		if !strings.Contains(link, "labels=") {
163
+			t.Errorf("rel=%s lost labels: %s", rel, link)
164
+		}
165
+	}
166
+}
167
+
168
+func TestLinkHeader_RelativeWhenBaseURLEmpty(t *testing.T) {
169
+	t.Parallel()
170
+	u, _ := url.Parse("/api/v1/repos?page=1&per_page=10")
171
+	p := apipage.Page{Current: 1, PerPage: 10, Total: 25}
172
+	h := p.LinkHeader("", u)
173
+	if !strings.HasPrefix(h, "</api/v1/repos?") {
174
+		t.Errorf("expected path-relative link; got %s", h)
175
+	}
176
+}
177
+
178
+// parseLink reimplements the gh-compatible parser used by
179
+// shithub-cli/internal/api.ParseLinkHeader. Kept in-test so apipage has
180
+// no cross-module dependency, but we exercise the same algorithm here.
181
+func parseLink(header string) map[string]string {
182
+	out := map[string]string{}
183
+	if header == "" {
184
+		return out
185
+	}
186
+	for _, entry := range splitLinkEntries(header) {
187
+		u, rel, ok := parseLinkEntry(entry)
188
+		if !ok {
189
+			continue
190
+		}
191
+		out[rel] = u
192
+	}
193
+	return out
194
+}
195
+
196
+func splitLinkEntries(header string) []string {
197
+	var (
198
+		entries []string
199
+		buf     strings.Builder
200
+		depth   int
201
+	)
202
+	for _, r := range header {
203
+		switch r {
204
+		case '<':
205
+			depth++
206
+		case '>':
207
+			if depth > 0 {
208
+				depth--
209
+			}
210
+		case ',':
211
+			if depth == 0 {
212
+				entries = append(entries, strings.TrimSpace(buf.String()))
213
+				buf.Reset()
214
+				continue
215
+			}
216
+		}
217
+		buf.WriteRune(r)
218
+	}
219
+	if buf.Len() > 0 {
220
+		entries = append(entries, strings.TrimSpace(buf.String()))
221
+	}
222
+	return entries
223
+}
224
+
225
+func parseLinkEntry(entry string) (linkURL, rel string, ok bool) {
226
+	lt := strings.Index(entry, "<")
227
+	gt := strings.Index(entry, ">")
228
+	if lt < 0 || gt < 0 || gt < lt {
229
+		return "", "", false
230
+	}
231
+	linkURL = entry[lt+1 : gt]
232
+	rest := entry[gt+1:]
233
+	for _, attr := range strings.Split(rest, ";") {
234
+		attr = strings.TrimSpace(attr)
235
+		if !strings.HasPrefix(attr, "rel=") {
236
+			continue
237
+		}
238
+		rel = strings.Trim(attr[len("rel="):], `"`)
239
+	}
240
+	if rel == "" {
241
+		return "", "", false
242
+	}
243
+	return linkURL, rel, true
244
+}
245
+
246
+func pageFor(link string) string {
247
+	idx := strings.Index(link, "?")
248
+	if idx < 0 {
249
+		return ""
250
+	}
251
+	q, err := url.ParseQuery(link[idx+1:])
252
+	if err != nil {
253
+		return ""
254
+	}
255
+	return q.Get("page")
256
+}
internal/web/handlers/api/checks.gomodified
9 lines changed — click to load
@@ -60,8 +60,7 @@ func (h *Handlers) resolveAPIRepo(w http.ResponseWriter, r *http.Request, action
6060
 		writeAPIError(w, http.StatusNotFound, "repo not found")
6161
 		return nil, false
6262
 	}
63
-	actor := policy.UserActor(auth.UserID, "", false, false)
64
-	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, policy.NewRepoRefFromRepo(repo)).Allow {
63
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), action, policy.NewRepoRefFromRepo(repo)).Allow {
6564
 		// Existence-leak: 404 instead of 403 when the actor can't see
6665
 		// the repo. The PAT-scope check above is the public 403; this
6766
 		// is the visibility gate.
internal/web/handlers/api/cross_test.goadded
280 lines changed — click to load
@@ -0,0 +1,280 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"io"
9
+	"log/slog"
10
+	"net/http"
11
+	"net/http/httptest"
12
+	"strings"
13
+	"testing"
14
+
15
+	"github.com/go-chi/chi/v5"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
19
+	"github.com/tenseleyFlow/shithub/internal/ratelimit"
20
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+	"github.com/tenseleyFlow/shithub/internal/version"
23
+	apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
24
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
25
+)
26
+
27
+// newCrossCuttingAPIRouter builds the smallest /api/v1 router we can —
28
+// no runner JWT, no secret box, no object store. Enough to exercise the
29
+// PATAuth + RequireScope + apilimit + meta surface.
30
+func newCrossCuttingAPIRouter(t *testing.T, pool *pgxpool.Pool) http.Handler {
31
+	t.Helper()
32
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
33
+	h, err := apih.New(apih.Deps{
34
+		Pool:        pool,
35
+		Logger:      logger,
36
+		RateLimiter: ratelimit.New(pool),
37
+		BaseURL:     "https://shithub.test",
38
+		APILimit: apilimit.Config{
39
+			AuthedPerHour: 5000,
40
+			AnonPerHour:   60,
41
+			Logger:        logger,
42
+		},
43
+	})
44
+	if err != nil {
45
+		t.Fatalf("api.New: %v", err)
46
+	}
47
+	r := chi.NewRouter()
48
+	h.Mount(r)
49
+	return r
50
+}
51
+
52
+func crossCuttingUser(t *testing.T, pool *pgxpool.Pool) int64 {
53
+	t.Helper()
54
+	user, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
55
+		Username:     "alice",
56
+		DisplayName:  "Alice",
57
+		PasswordHash: runnerAPIFixtureHash,
58
+	})
59
+	if err != nil {
60
+		t.Fatalf("CreateUser: %v", err)
61
+	}
62
+	return user.ID
63
+}
64
+
65
+func TestCrossCutting_AuthFailureReturnsJSONEnvelope(t *testing.T) {
66
+	pool := dbtest.NewTestDB(t)
67
+	router := newCrossCuttingAPIRouter(t, pool)
68
+
69
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
70
+	req.Header.Set("Authorization", "Bearer not-a-real-token")
71
+	rr := httptest.NewRecorder()
72
+	router.ServeHTTP(rr, req)
73
+
74
+	if rr.Code != http.StatusUnauthorized {
75
+		t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
76
+	}
77
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
78
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
79
+	}
80
+	if wa := rr.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "Bearer") {
81
+		t.Errorf("WWW-Authenticate missing Bearer challenge: %q", wa)
82
+	}
83
+	var envelope struct {
84
+		Error string `json:"error"`
85
+	}
86
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
87
+		t.Fatalf("decode error envelope: %v; body=%s", err, rr.Body.String())
88
+	}
89
+	if envelope.Error == "" {
90
+		t.Errorf("error envelope empty: %s", rr.Body.String())
91
+	}
92
+}
93
+
94
+func TestCrossCutting_ScopeRejectReturnsJSONEnvelope(t *testing.T) {
95
+	pool := dbtest.NewTestDB(t)
96
+	router := newCrossCuttingAPIRouter(t, pool)
97
+	userID := crossCuttingUser(t, pool)
98
+	// User has only user:read; /api/v1/user needs user:read, so to
99
+	// exercise a scope reject we use a different route that requires
100
+	// repo:write. We don't have a repo wired here, so we forge a path
101
+	// the scope-decorator wrapped around check-runs, knowing it will
102
+	// short-circuit on scope before policy resolution. The scope check
103
+	// runs before the resolveAPIRepo call, so we get 403 directly.
104
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
105
+
106
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/check-runs", strings.NewReader(`{}`))
107
+	req.Header.Set("Authorization", "Bearer "+token)
108
+	rr := httptest.NewRecorder()
109
+	router.ServeHTTP(rr, req)
110
+
111
+	if rr.Code != http.StatusForbidden {
112
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
113
+	}
114
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
115
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
116
+	}
117
+	if want := string(pat.ScopeRepoWrite); rr.Header().Get("X-Accepted-OAuth-Scopes") != want {
118
+		t.Errorf("X-Accepted-OAuth-Scopes: got %q, want %q", rr.Header().Get("X-Accepted-OAuth-Scopes"), want)
119
+	}
120
+	if got := rr.Header().Get("X-OAuth-Scopes"); got != string(pat.ScopeUserRead) {
121
+		t.Errorf("X-OAuth-Scopes: got %q, want %q", got, pat.ScopeUserRead)
122
+	}
123
+	var envelope struct {
124
+		Error string `json:"error"`
125
+	}
126
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
127
+		t.Fatalf("decode error envelope: %v; body=%s", err, rr.Body.String())
128
+	}
129
+	if !strings.Contains(envelope.Error, "scope") {
130
+		t.Errorf("error envelope: got %q, want one mentioning scope", envelope.Error)
131
+	}
132
+}
133
+
134
+func TestCrossCutting_XOAuthScopesOnSuccess(t *testing.T) {
135
+	pool := dbtest.NewTestDB(t)
136
+	router := newCrossCuttingAPIRouter(t, pool)
137
+	userID := crossCuttingUser(t, pool)
138
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
139
+
140
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
141
+	req.Header.Set("Authorization", "Bearer "+token)
142
+	rr := httptest.NewRecorder()
143
+	router.ServeHTTP(rr, req)
144
+
145
+	if rr.Code != http.StatusOK {
146
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
147
+	}
148
+	if got := rr.Header().Get("X-OAuth-Scopes"); got != string(pat.ScopeUserRead) {
149
+		t.Errorf("X-OAuth-Scopes: got %q, want %q", got, pat.ScopeUserRead)
150
+	}
151
+}
152
+
153
+func TestCrossCutting_RateLimitHeadersStampedAuthed(t *testing.T) {
154
+	pool := dbtest.NewTestDB(t)
155
+	router := newCrossCuttingAPIRouter(t, pool)
156
+	userID := crossCuttingUser(t, pool)
157
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
158
+
159
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
160
+	req.Header.Set("Authorization", "Bearer "+token)
161
+	req.RemoteAddr = "10.0.0.5:12345"
162
+	rr := httptest.NewRecorder()
163
+	router.ServeHTTP(rr, req)
164
+
165
+	if rr.Code != http.StatusOK {
166
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
167
+	}
168
+	if got := rr.Header().Get("X-RateLimit-Limit"); got != "5000" {
169
+		t.Errorf("X-RateLimit-Limit: got %q, want 5000", got)
170
+	}
171
+	if rr.Header().Get("X-RateLimit-Remaining") == "" {
172
+		t.Errorf("X-RateLimit-Remaining missing")
173
+	}
174
+	if rr.Header().Get("X-RateLimit-Reset") == "" {
175
+		t.Errorf("X-RateLimit-Reset missing")
176
+	}
177
+}
178
+
179
+func TestCrossCutting_RateLimitHeadersStampedAnon(t *testing.T) {
180
+	pool := dbtest.NewTestDB(t)
181
+	router := newCrossCuttingAPIRouter(t, pool)
182
+
183
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
184
+	req.RemoteAddr = "10.0.0.6:54321"
185
+	rr := httptest.NewRecorder()
186
+	router.ServeHTTP(rr, req)
187
+
188
+	if rr.Code != http.StatusOK {
189
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
190
+	}
191
+	if got := rr.Header().Get("X-RateLimit-Limit"); got != "60" {
192
+		t.Errorf("X-RateLimit-Limit (anon): got %q, want 60", got)
193
+	}
194
+}
195
+
196
+func TestCrossCutting_MetaPayload(t *testing.T) {
197
+	pool := dbtest.NewTestDB(t)
198
+	router := newCrossCuttingAPIRouter(t, pool)
199
+
200
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
201
+	rr := httptest.NewRecorder()
202
+	router.ServeHTTP(rr, req)
203
+
204
+	if rr.Code != http.StatusOK {
205
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
206
+	}
207
+	var resp struct {
208
+		Version      string   `json:"version"`
209
+		Commit       string   `json:"commit"`
210
+		BuiltAt      string   `json:"built_at"`
211
+		Capabilities []string `json:"capabilities"`
212
+	}
213
+	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
214
+		t.Fatalf("decode meta: %v; body=%s", err, rr.Body.String())
215
+	}
216
+	if resp.Version != version.Version {
217
+		t.Errorf("version: got %q, want %q", resp.Version, version.Version)
218
+	}
219
+	if len(resp.Capabilities) == 0 {
220
+		t.Errorf("capabilities empty: %#v", resp)
221
+	}
222
+	if !containsString(resp.Capabilities, "pat-auth") {
223
+		t.Errorf("capabilities missing pat-auth: %v", resp.Capabilities)
224
+	}
225
+}
226
+
227
+func TestCrossCutting_RateLimitDeniedJSON(t *testing.T) {
228
+	pool := dbtest.NewTestDB(t)
229
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
230
+	// Tiny limits so we can trigger the deny path without 5001 requests.
231
+	h, err := apih.New(apih.Deps{
232
+		Pool:        pool,
233
+		Logger:      logger,
234
+		RateLimiter: ratelimit.New(pool),
235
+		BaseURL:     "https://shithub.test",
236
+		APILimit: apilimit.Config{
237
+			AuthedPerHour: 1,
238
+			AnonPerHour:   1,
239
+			Logger:        logger,
240
+		},
241
+	})
242
+	if err != nil {
243
+		t.Fatalf("api.New: %v", err)
244
+	}
245
+	router := chi.NewRouter()
246
+	h.Mount(router)
247
+
248
+	// First anon request consumes the entire budget.
249
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
250
+	req.RemoteAddr = "10.0.0.99:11111"
251
+	rr := httptest.NewRecorder()
252
+	router.ServeHTTP(rr, req)
253
+	if rr.Code != http.StatusOK {
254
+		t.Fatalf("first call status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
255
+	}
256
+
257
+	// Second request exceeds the budget.
258
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
259
+	req.RemoteAddr = "10.0.0.99:11112"
260
+	rr = httptest.NewRecorder()
261
+	router.ServeHTTP(rr, req)
262
+	if rr.Code != http.StatusTooManyRequests {
263
+		t.Fatalf("second call status: got %d, want 429; body=%s", rr.Code, rr.Body.String())
264
+	}
265
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
266
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
267
+	}
268
+	if rr.Header().Get("Retry-After") == "" {
269
+		t.Errorf("Retry-After missing on 429")
270
+	}
271
+	var envelope struct {
272
+		Error string `json:"error"`
273
+	}
274
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
275
+		t.Fatalf("decode 429 envelope: %v; body=%s", err, rr.Body.String())
276
+	}
277
+	if !strings.Contains(envelope.Error, "rate") {
278
+		t.Errorf("429 error: got %q", envelope.Error)
279
+	}
280
+}
internal/web/handlers/api/meta.goadded
51 lines changed — click to load
@@ -0,0 +1,51 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"net/http"
7
+
8
+	"github.com/go-chi/chi/v5"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/version"
11
+)
12
+
13
+// APICapabilities is the canonical list of feature capabilities exposed
14
+// by this server. Each S50 batch (§1 user, §2 repos, §3 issues, …)
15
+// appends its capability name here so the CLI can gate behavior. The
16
+// values are short kebab-case identifiers, GitHub-flavored when an
17
+// obvious mapping exists.
18
+//
19
+// Append-only is a soft contract: removing an entry is a breaking
20
+// change for clients that read this list.
21
+var APICapabilities = []string{
22
+	"pat-auth",
23
+	"check-runs",
24
+	"stars",
25
+	"actions-lifecycle",
26
+}
27
+
28
+type metaResponse struct {
29
+	Version      string   `json:"version"`
30
+	Commit       string   `json:"commit"`
31
+	BuiltAt      string   `json:"built_at"`
32
+	Capabilities []string `json:"capabilities"`
33
+}
34
+
35
+// mountMeta registers the capability-discovery endpoint. /api/v1/meta is
36
+// unauthenticated by design — clients use it to negotiate capabilities
37
+// before they have credentials.
38
+func (h *Handlers) mountMeta(r chi.Router) {
39
+	r.Get("/api/v1/meta", h.metaGet)
40
+}
41
+
42
+func (h *Handlers) metaGet(w http.ResponseWriter, _ *http.Request) {
43
+	caps := make([]string, len(APICapabilities))
44
+	copy(caps, APICapabilities)
45
+	writeJSON(w, http.StatusOK, metaResponse{
46
+		Version:      version.Version,
47
+		Commit:       version.Commit,
48
+		BuiltAt:      version.BuiltAt,
49
+		Capabilities: caps,
50
+	})
51
+}
internal/web/handlers/api/runners.gomodified
149 lines changed — click to load
@@ -20,6 +20,7 @@ import (
2020
 	"github.com/jackc/pgx/v5"
2121
 	"github.com/jackc/pgx/v5/pgtype"
2222
 
23
+	actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events"
2324
 	"github.com/tenseleyFlow/shithub/internal/actions/finalize"
2425
 	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
2526
 	"github.com/tenseleyFlow/shithub/internal/actions/logstream"
@@ -220,7 +221,21 @@ func (h *Handlers) claimRunnerJob(
220221
 		committed = true
221222
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, nil
222223
 	}
223
-	if err := q.MarkWorkflowRunRunning(ctx, tx, job.RunID); err != nil {
224
+	run, err := q.StartWorkflowRun(ctx, tx, job.RunID)
225
+	switch {
226
+	case err == nil:
227
+		if err := actionsevents.EmitRunTx(ctx, tx, run, actionsevents.ActionRunning); err != nil {
228
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
229
+		}
230
+	case errors.Is(err, pgx.ErrNoRows):
231
+		run, err = q.GetWorkflowRunByID(ctx, tx, job.RunID)
232
+		if err != nil {
233
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
234
+		}
235
+	default:
236
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
237
+	}
238
+	if err := actionsevents.EmitJobTx(ctx, tx, run, claimRowWorkflowJob(job), actionsevents.ActionRunning); err != nil {
224239
 		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
225240
 	}
226241
 	steps, err := q.ListRunnerStepsForJob(ctx, tx, job.ID)
@@ -733,15 +748,45 @@ func (h *Handlers) applyJobStatus(
733748
 		return actionsdb.WorkflowJob{}, false, "", err
734749
 	}
735750
 	runConclusion, complete := deriveWorkflowRunConclusion(jobs)
751
+	runAfter, err := q.GetWorkflowRunByID(ctx, tx, updated.RunID)
752
+	if err != nil {
753
+		return actionsdb.WorkflowJob{}, false, "", err
754
+	}
755
+	runBefore := runAfter
756
+	runStarted := false
757
+	runTerminalChanged := false
736758
 	if complete {
737
-		if _, err := q.CompleteWorkflowRun(ctx, tx, actionsdb.CompleteWorkflowRunParams{
759
+		runAfter, err = q.CompleteWorkflowRun(ctx, tx, actionsdb.CompleteWorkflowRunParams{
738760
 			ID:         updated.RunID,
739761
 			Conclusion: runConclusion,
740
-		}); err != nil {
762
+		})
763
+		if err != nil {
764
+			return actionsdb.WorkflowJob{}, false, "", err
765
+		}
766
+		runTerminalChanged = workflowRunLifecycleChanged(runBefore, runAfter)
767
+	} else {
768
+		startedRun, err := q.StartWorkflowRun(ctx, tx, updated.RunID)
769
+		if err == nil {
770
+			runAfter = startedRun
771
+			runStarted = true
772
+		} else if !errors.Is(err, pgx.ErrNoRows) {
773
+			return actionsdb.WorkflowJob{}, false, "", err
774
+		}
775
+	}
776
+	if jobLifecycleChanged(job, updated) {
777
+		if err := actionsevents.EmitJobTx(ctx, tx, runAfter, updated, workflowJobEventAction(updated.Status)); err != nil {
778
+			return actionsdb.WorkflowJob{}, false, "", err
779
+		}
780
+	}
781
+	if runStarted {
782
+		if err := actionsevents.EmitRunTx(ctx, tx, runAfter, actionsevents.ActionRunning); err != nil {
783
+			return actionsdb.WorkflowJob{}, false, "", err
784
+		}
785
+	}
786
+	if complete && runTerminalChanged {
787
+		if err := actionsevents.EmitRunTx(ctx, tx, runAfter, workflowRunEventAction(runAfter.Status)); err != nil {
741788
 			return actionsdb.WorkflowJob{}, false, "", err
742789
 		}
743
-	} else if err := q.MarkWorkflowRunRunning(ctx, tx, updated.RunID); err != nil {
744
-		return actionsdb.WorkflowJob{}, false, "", err
745790
 	}
746791
 	if err := tx.Commit(ctx); err != nil {
747792
 		return actionsdb.WorkflowJob{}, false, "", err
@@ -755,6 +800,71 @@ func (h *Handlers) applyJobStatus(
755800
 	return updated, complete, runConclusion, nil
756801
 }
757802
 
803
+func claimRowWorkflowJob(row actionsdb.ClaimQueuedWorkflowJobRow) actionsdb.WorkflowJob {
804
+	return actionsdb.WorkflowJob{
805
+		ID:              row.ID,
806
+		RunID:           row.RunID,
807
+		JobIndex:        row.JobIndex,
808
+		JobKey:          row.JobKey,
809
+		JobName:         row.JobName,
810
+		RunsOn:          row.RunsOn,
811
+		RunnerID:        row.RunnerID,
812
+		NeedsJobs:       row.NeedsJobs,
813
+		IfExpr:          row.IfExpr,
814
+		TimeoutMinutes:  row.TimeoutMinutes,
815
+		Permissions:     row.Permissions,
816
+		JobEnv:          row.JobEnv,
817
+		Status:          row.Status,
818
+		Conclusion:      row.Conclusion,
819
+		CancelRequested: row.CancelRequested,
820
+		StartedAt:       row.StartedAt,
821
+		CompletedAt:     row.CompletedAt,
822
+		Version:         row.Version,
823
+		CreatedAt:       row.CreatedAt,
824
+		UpdatedAt:       row.UpdatedAt,
825
+	}
826
+}
827
+
828
+func jobLifecycleChanged(before, after actionsdb.WorkflowJob) bool {
829
+	if before.Status != after.Status {
830
+		return true
831
+	}
832
+	if before.Conclusion.Valid != after.Conclusion.Valid {
833
+		return true
834
+	}
835
+	return before.Conclusion.Valid && before.Conclusion.CheckConclusion != after.Conclusion.CheckConclusion
836
+}
837
+
838
+func workflowRunLifecycleChanged(before, after actionsdb.WorkflowRun) bool {
839
+	if before.Status != after.Status {
840
+		return true
841
+	}
842
+	if before.Conclusion.Valid != after.Conclusion.Valid {
843
+		return true
844
+	}
845
+	return before.Conclusion.Valid && before.Conclusion.CheckConclusion != after.Conclusion.CheckConclusion
846
+}
847
+
848
+func workflowJobEventAction(status actionsdb.WorkflowJobStatus) string {
849
+	switch status {
850
+	case actionsdb.WorkflowJobStatusCancelled:
851
+		return actionsevents.ActionCancelled
852
+	case actionsdb.WorkflowJobStatusCompleted, actionsdb.WorkflowJobStatusSkipped:
853
+		return actionsevents.ActionCompleted
854
+	case actionsdb.WorkflowJobStatusRunning:
855
+		return actionsevents.ActionRunning
856
+	default:
857
+		return actionsevents.ActionQueued
858
+	}
859
+}
860
+
861
+func workflowRunEventAction(status actionsdb.WorkflowRunStatus) string {
862
+	if status == actionsdb.WorkflowRunStatusCancelled {
863
+		return actionsevents.ActionCancelled
864
+	}
865
+	return actionsevents.ActionCompleted
866
+}
867
+
758868
 func deriveWorkflowRunConclusion(jobs []actionsdb.ListJobsForRunRow) (actionsdb.CheckConclusion, bool) {
759869
 	if len(jobs) == 0 {
760870
 		return actionsdb.CheckConclusionFailure, true
internal/web/handlers/api/stars.gomodified
12 lines changed — click to load
@@ -122,11 +122,7 @@ func (h *Handlers) resolveStarTargetRepo(w http.ResponseWriter, r *http.Request)
122122
 		writeAPIError(w, http.StatusNotFound, "repo not found")
123123
 		return reposdb.Repo{}, false
124124
 	}
125
-	// PAT-auth path: the middleware already rejected suspended
126
-	// accounts; passing IsSuspended=false here is correct by
127
-	// construction (documented in docs/internal/permissions.md).
128
-	actor := policy.UserActor(auth.UserID, "", false, false)
129
-	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionStarCreate, policy.NewRepoRefFromRepo(repo)).Allow {
125
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionStarCreate, policy.NewRepoRefFromRepo(repo)).Allow {
130126
 		writeAPIError(w, http.StatusNotFound, "repo not found")
131127
 		return reposdb.Repo{}, false
132128
 	}
internal/web/handlers/explore.gomodified
51 lines changed — click to load
@@ -52,6 +52,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
5252
 			}
5353
 			return social.PublicFeed(r.Context(), deps, cursor, limit)
5454
 		})
55
+		if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
56
+			h.renderFeedFragment(w, r, feed, hasNext, nextURL)
57
+			return
58
+		}
5559
 		if viewer.ID != 0 {
5660
 			var err error
5761
 			topRepos, err = social.DashboardRepos(r.Context(), deps, viewer.ID, 30)
@@ -73,6 +77,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
7377
 			h.logger.WarnContext(r.Context(), "explore trending users", "error", err)
7478
 		}
7579
 	}
80
+	if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
81
+		h.renderFeedFragment(w, r, feed, hasNext, nextURL)
82
+		return
83
+	}
7684
 
7785
 	pageHeading := title
7886
 	feedHeading := "Public activity"
@@ -102,6 +110,7 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
102110
 		"TrendingRepos":  trendingRepos,
103111
 		"TrendingUsers":  trendingUsers,
104112
 		"Path":           path,
113
+		"UseHTMX":        true,
105114
 	}
106115
 	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
107116
 		if h.logger != nil {
@@ -111,6 +120,24 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
111120
 	}
112121
 }
113122
 
123
+func (h exploreHandler) renderFeedFragment(w http.ResponseWriter, r *http.Request, feed []social.FeedItem, hasNext bool, nextURL string) {
124
+	data := map[string]any{
125
+		"Feed":        feed,
126
+		"FeedHasNext": hasNext,
127
+		"FeedNextURL": nextURL,
128
+	}
129
+	if err := h.render.RenderFragment(w, "explore/feed_page", data); err != nil {
130
+		if h.logger != nil {
131
+			h.logger.ErrorContext(r.Context(), "render explore feed fragment", "error", err)
132
+		}
133
+		http.Error(w, "internal server error", http.StatusInternalServerError)
134
+	}
135
+}
136
+
137
+func isExploreFeedFragmentRequest(r *http.Request) bool {
138
+	return r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("before") != ""
139
+}
140
+
114141
 func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) {
115142
 	items, err := load(parseFeedCursor(r), feedDisplayLimit+1)
116143
 	if err != nil {
internal/web/handlers/explore_test.goadded
71 lines changed — click to load
@@ -0,0 +1,71 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"net/url"
8
+	"testing"
9
+	"time"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/social"
12
+)
13
+
14
+func TestExploreFeedFragmentRequestRequiresHTMXAndCursor(t *testing.T) {
15
+	t.Parallel()
16
+
17
+	req := httptest.NewRequest("GET", "/explore?before=2026-05-12T00:00:00Z~42", nil)
18
+	if isExploreFeedFragmentRequest(req) {
19
+		t.Fatal("plain cursor request should stay a full-page no-JS fallback")
20
+	}
21
+
22
+	req.Header.Set("HX-Request", "true")
23
+	if !isExploreFeedFragmentRequest(req) {
24
+		t.Fatal("HTMX cursor request should render feed fragment")
25
+	}
26
+
27
+	req = httptest.NewRequest("GET", "/explore", nil)
28
+	req.Header.Set("HX-Request", "true")
29
+	if isExploreFeedFragmentRequest(req) {
30
+		t.Fatal("HTMX first-page request should not render feed fragment")
31
+	}
32
+}
33
+
34
+func TestFeedPageForBuildsNextCursorFromLastDisplayedItem(t *testing.T) {
35
+	t.Parallel()
36
+
37
+	req := httptest.NewRequest("GET", "/explore?tab=activity", nil)
38
+	base := time.Date(2026, 5, 12, 14, 0, 0, 0, time.UTC)
39
+	items := make([]social.FeedItem, 0, feedDisplayLimit+1)
40
+	for i := int32(0); i < feedDisplayLimit+1; i++ {
41
+		items = append(items, social.FeedItem{
42
+			ID:        int64(1000 + i),
43
+			CreatedAt: base.Add(-time.Duration(i) * time.Minute),
44
+		})
45
+	}
46
+
47
+	got, hasNext, nextURL := feedPageFor(req, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
48
+		if !cursor.BeforeCreatedAt.IsZero() || cursor.BeforeID != 0 {
49
+			t.Fatalf("first page cursor = %+v, want zero", cursor)
50
+		}
51
+		if limit != feedDisplayLimit+1 {
52
+			t.Fatalf("limit = %d, want %d", limit, feedDisplayLimit+1)
53
+		}
54
+		return items, nil
55
+	})
56
+
57
+	if len(got) != int(feedDisplayLimit) {
58
+		t.Fatalf("display item count = %d, want %d", len(got), feedDisplayLimit)
59
+	}
60
+	if !hasNext {
61
+		t.Fatal("hasNext = false, want true")
62
+	}
63
+	wantCursor := got[len(got)-1].CreatedAt.UTC().Format(time.RFC3339Nano) + "~" + "1019"
64
+	parsed, err := url.Parse(nextURL)
65
+	if err != nil {
66
+		t.Fatalf("parse nextURL: %v", err)
67
+	}
68
+	if parsed.Query().Get("tab") != "activity" || parsed.Query().Get("before") != wantCursor {
69
+		t.Fatalf("nextURL = %q, want preserved query and cursor %q", nextURL, wantCursor)
70
+	}
71
+}
internal/web/handlers/handlers.gomodified
31 lines changed — click to load
@@ -88,6 +88,10 @@ type Deps struct {
8888
 	// workflow_dispatch endpoint (S41b). Auth-required + per-handler
8989
 	// repo-write check.
9090
 	RepoActionsAPIMounter func(chi.Router)
91
+	// RepoActionsStreamMounter registers long-lived Actions log-stream
92
+	// routes. It MUST bypass response compression and request timeout;
93
+	// the handler still runs the normal repo-read policy gate.
94
+	RepoActionsStreamMounter func(chi.Router)
9195
 	// RepoSettingsGeneralMounter registers the General/Access tabs and
9296
 	// the deferred-tab placeholders (webhooks, keys, notifications,
9397
 	// tags) under /{owner}/{repo}/settings/* (S32). Auth-required.
@@ -255,9 +259,20 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
255259
 		})
256260
 	}
257261
 
262
+	// Actions step-log SSE also streams for minutes. Keep it out of the
263
+	// app group's timeout/compression stack so EventSource receives each
264
+	// event as the handler flushes it. Browser CSRF protection is not
265
+	// needed for this GET-only route; repo visibility is enforced inside
266
+	// the handler through policy.ActionRepoRead.
267
+	if deps.RepoActionsStreamMounter != nil {
268
+		r.Group(func(r chi.Router) {
269
+			deps.RepoActionsStreamMounter(r)
270
+		})
271
+	}
272
+
258273
 	// Application routes — CSRF protected. Compress + Timeout live in
259274
 	// this group (and the static one above) rather than globally so the
260
-	// git-HTTP group can opt out.
275
+	// streaming groups can opt out.
261276
 	r.Group(func(r chi.Router) {
262277
 		r.Use(middleware.Compress)
263278
 		r.Use(middleware.Timeout(30 * time.Second))
internal/web/handlers/handlers_test.gomodified
51 lines changed — click to load
@@ -9,6 +9,8 @@ import (
99
 	"net/http/httptest"
1010
 	"strings"
1111
 	"testing"
12
+
13
+	"github.com/go-chi/chi/v5"
1214
 )
1315
 
1416
 func TestHandlers(t *testing.T) {
@@ -135,3 +137,43 @@ func TestHealthzHEAD(t *testing.T) {
135137
 		t.Fatalf("HEAD /healthz: status %d, want 200", rec.Code)
136138
 	}
137139
 }
140
+
141
+func TestActionsLogStreamRouteBypassesCompressAndTimeout(t *testing.T) {
142
+	t.Parallel()
143
+
144
+	mux := http.NewServeMux()
145
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
146
+	if err := Register(mux, Deps{
147
+		Logger:      logger,
148
+		TemplatesFS: testTemplatesFS(t),
149
+		StaticFS:    testStaticFS(t),
150
+		LogoSVG:     `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`,
151
+		RepoActionsStreamMounter: func(r chi.Router) {
152
+			r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", func(w http.ResponseWriter, r *http.Request) {
153
+				w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
154
+				if _, ok := r.Context().Deadline(); ok {
155
+					_, _ = io.WriteString(w, "deadline")
156
+					return
157
+				}
158
+				_, _ = io.WriteString(w, "no-deadline")
159
+			})
160
+		},
161
+	}); err != nil {
162
+		t.Fatalf("Register: %v", err)
163
+	}
164
+
165
+	req := httptest.NewRequest(http.MethodGet, "/octo/demo/actions/runs/1/jobs/0/steps/0/log/stream", nil)
166
+	req.Header.Set("Accept-Encoding", "gzip")
167
+	rec := httptest.NewRecorder()
168
+	mux.ServeHTTP(rec, req)
169
+
170
+	if rec.Code != http.StatusOK {
171
+		t.Fatalf("status: got %d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String())
172
+	}
173
+	if got := rec.Header().Get("Content-Encoding"); got != "" {
174
+		t.Fatalf("Content-Encoding: got %q, want empty", got)
175
+	}
176
+	if got := rec.Body.String(); got != "no-deadline" {
177
+		t.Fatalf("body: got %q, want no-deadline", got)
178
+	}
179
+}
internal/web/handlers/profile/overview.gomodified
61 lines changed — click to load
@@ -122,6 +122,8 @@ type profileContributionRepo struct {
122122
 	Repo                  reposdb.Repo
123123
 	OwnerSlug             string
124124
 	AllowIdentityFallback bool
125
+	IsPrivate             bool
126
+	IsProfileOwnedPublic  bool
125127
 }
126128
 
127129
 func (h *Handlers) visibleUserRepos(ctx context.Context, userID int64, viewer middleware.CurrentUser) []reposdb.Repo {
@@ -296,7 +298,7 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
296298
 		if !source.Repo.CreatedAt.Valid || created.Before(windowStart) || !created.Before(windowEnd) {
297299
 			continue
298300
 		}
299
-		if source.Repo.OwnerUserID.Int64 != user.ID || source.Repo.Visibility != reposdb.RepoVisibilityPublic {
301
+		if !source.IsProfileOwnedPublic {
300302
 			continue
301303
 		}
302304
 		activity.addCreatedRepo(created, source)
@@ -370,7 +372,7 @@ func newProfileActivityBuilder() *profileActivityBuilder {
370372
 }
371373
 
372374
 func (b *profileActivityBuilder) addCommit(day time.Time, source profileContributionRepo) {
373
-	isPrivate := source.Repo.Visibility == reposdb.RepoVisibilityPrivate
375
+	isPrivate := source.IsPrivate
374376
 	fullName := source.OwnerSlug + "/" + source.Repo.Name
375377
 	url := "/" + url.PathEscape(source.OwnerSlug) + "/" + url.PathEscape(source.Repo.Name)
376378
 	if isPrivate {
@@ -600,15 +602,15 @@ func (h *Handlers) addProfileThreadActivity(ctx context.Context, user usersdb.Us
600602
 	}
601603
 	deps := policy.Deps{Pool: h.d.Pool}
602604
 	for _, row := range rows {
603
-		if row.Visibility == issuesdb.RepoVisibilityPrivate && !user.IncludePrivateContributions {
604
-			continue
605
-		}
606605
 		ref := policy.RepoRef{
607606
 			ID:          row.RepoID,
608607
 			OwnerUserID: row.OwnerUserID.Int64,
609608
 			OwnerOrgID:  row.OwnerOrgID.Int64,
610609
 			Visibility:  string(row.Visibility),
611610
 		}
611
+		if ref.IsPrivate() && !user.IncludePrivateContributions {
612
+			continue
613
+		}
612614
 		if !policy.IsVisibleTo(ctx, deps, actor, ref) {
613615
 			continue
614616
 		}
@@ -658,6 +660,8 @@ func (h *Handlers) profileContributionRepos(ctx context.Context, user usersdb.Us
658660
 			Repo:                  repo,
659661
 			OwnerSlug:             ownerSlug,
660662
 			AllowIdentityFallback: allowIdentityFallback,
663
+			IsPrivate:             repoRef.IsPrivate(),
664
+			IsProfileOwnedPublic:  isProfileOwnedPublicRepo(repo, repoRef, user.ID),
661665
 		})
662666
 	}
663667
 
@@ -697,6 +701,11 @@ func (h *Handlers) profileContributionRepos(ctx context.Context, user usersdb.Us
697701
 	return out
698702
 }
699703
 
704
+func isProfileOwnedPublicRepo(repo reposdb.Repo, ref policy.RepoRef, userID int64) bool {
705
+	ownerUserID := repo.OwnerUserID.Int64
706
+	return ownerUserID == userID && ref.IsPublic()
707
+}
708
+
700709
 func selectedContributionYear(query url.Values, currentYear int) int {
701710
 	for _, key := range []string{"year", "from"} {
702711
 		raw := strings.TrimSpace(query.Get(key))
internal/web/handlers/profile/profile_test.gomodified
16 lines changed — click to load
@@ -640,8 +640,14 @@ func TestProfile_ContributionActivityIncludesCommitsReposIssuesAndPulls(t *testi
640640
 			t.Errorf("missing %q in body: %s", want, body)
641641
 		}
642642
 	}
643
-	if strings.Contains(body, strconv.FormatInt(oldRepoID, 10)) {
644
-		t.Fatalf("test template leaked implementation ids unexpectedly: %s", body)
643
+	for _, leak := range []string{
644
+		fmt.Sprintf("repo_id=%d", oldRepoID),
645
+		fmt.Sprintf(`data-repo-id="%d"`, oldRepoID),
646
+		fmt.Sprintf("RepoID:%d", oldRepoID),
647
+	} {
648
+		if strings.Contains(body, leak) {
649
+			t.Fatalf("test template leaked implementation id marker %q unexpectedly: %s", leak, body)
650
+		}
645651
 	}
646652
 }
647653
 
internal/web/handlers/profile/stars_tab.gomodified
8 lines changed — click to load
@@ -107,7 +107,7 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us
107107
 			URL:             "/" + url.PathEscape(ownerName) + "/" + url.PathEscape(row.RepoName),
108108
 			Description:     row.Description,
109109
 			Visibility:      string(row.Visibility),
110
-			IsPrivate:       row.Visibility == socialdb.RepoVisibilityPrivate,
110
+			IsPrivate:       ref.IsPrivate(),
111111
 			StarCount:       row.StarCount,
112112
 			PrimaryLanguage: language,
113113
 			LanguageColor:   template.CSS(orgLanguageColor(language)), //nolint:gosec // server-side constant map.
internal/web/handlers/repo/actions_atom.goadded
123 lines changed — click to load
@@ -0,0 +1,123 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"encoding/xml"
7
+	"fmt"
8
+	"io"
9
+	"net/http"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+)
17
+
18
+const actionsAtomRunLimit = int32(50)
19
+
20
+func (h *Handlers) repoActionsAtom(w http.ResponseWriter, r *http.Request) {
21
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
22
+	if !ok {
23
+		return
24
+	}
25
+	runs, err := actionsdb.New().ListWorkflowRunsForRepo(r.Context(), h.d.Pool, actionsdb.ListWorkflowRunsForRepoParams{
26
+		RepoID:     row.ID,
27
+		PageLimit:  actionsAtomRunLimit,
28
+		PageOffset: 0,
29
+	})
30
+	if err != nil {
31
+		h.d.Logger.WarnContext(r.Context(), "repo actions atom: list workflow runs", "repo_id", row.ID, "error", err)
32
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
33
+		return
34
+	}
35
+	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
36
+	writeActionsAtom(w, owner.Username, row.Name, runs, time.Now())
37
+}
38
+
39
+func writeActionsAtom(w io.Writer, owner, repoName string, runs []actionsdb.ListWorkflowRunsForRepoRow, now time.Time) {
40
+	type atomAuthor struct {
41
+		Name string `xml:"name"`
42
+	}
43
+	type atomEntry struct {
44
+		ID      string     `xml:"id"`
45
+		Title   string     `xml:"title"`
46
+		Updated string     `xml:"updated"`
47
+		Author  atomAuthor `xml:"author"`
48
+		Summary string     `xml:"summary"`
49
+		Link    struct {
50
+			Href string `xml:"href,attr"`
51
+			Rel  string `xml:"rel,attr,omitempty"`
52
+		} `xml:"link"`
53
+	}
54
+	type atomFeed struct {
55
+		XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
56
+		Title   string   `xml:"title"`
57
+		ID      string   `xml:"id"`
58
+		Updated string   `xml:"updated"`
59
+		Link    struct {
60
+			Href string `xml:"href,attr"`
61
+			Rel  string `xml:"rel,attr,omitempty"`
62
+		} `xml:"link"`
63
+		Entries []atomEntry `xml:"entry"`
64
+	}
65
+
66
+	feedUpdated := now.UTC()
67
+	if len(runs) > 0 {
68
+		feedUpdated = pgTime(runs[0].UpdatedAt, runs[0].CreatedAt.Time).UTC()
69
+	}
70
+	feed := atomFeed{
71
+		Title:   fmt.Sprintf("%s/%s Actions runs", owner, repoName),
72
+		ID:      fmt.Sprintf("urn:shithub:actions:%s:%s", owner, repoName),
73
+		Updated: feedUpdated.Format(time.RFC3339),
74
+	}
75
+	feed.Link.Href = fmt.Sprintf("/%s/%s/actions.atom", owner, repoName)
76
+	feed.Link.Rel = "self"
77
+
78
+	for _, run := range runs {
79
+		var e atomEntry
80
+		e.ID = fmt.Sprintf("urn:shithub:workflow_run:%d", run.ID)
81
+		e.Title = actionsAtomRunTitle(run)
82
+		e.Updated = pgTime(run.UpdatedAt, run.CreatedAt.Time).UTC().Format(time.RFC3339)
83
+		e.Author.Name = actionsAtomActor(run.ActorUsername)
84
+		e.Summary = actionsAtomRunSummary(run)
85
+		e.Link.Href = fmt.Sprintf("/%s/%s/actions/runs/%d", owner, repoName, run.RunIndex)
86
+		feed.Entries = append(feed.Entries, e)
87
+	}
88
+
89
+	enc := xml.NewEncoder(w)
90
+	enc.Indent("", "  ")
91
+	_, _ = io.WriteString(w, xml.Header)
92
+	_ = enc.Encode(feed)
93
+	_ = enc.Flush()
94
+}
95
+
96
+func actionsAtomRunTitle(run actionsdb.ListWorkflowRunsForRepoRow) string {
97
+	title := workflowDisplayName(run.WorkflowName, run.WorkflowFile)
98
+	state, _, _ := workflowRunState(run.Status, run.Conclusion)
99
+	return fmt.Sprintf("%s #%d %s", title, run.RunIndex, strings.ToLower(state))
100
+}
101
+
102
+func actionsAtomRunSummary(run actionsdb.ListWorkflowRunsForRepoRow) string {
103
+	parts := []string{
104
+		"Workflow: " + workflowDisplayName(run.WorkflowName, run.WorkflowFile),
105
+		"Event: " + workflowRunEventLabel(string(run.Event)),
106
+		"Status: " + string(run.Status),
107
+		"Branch: " + run.HeadRef,
108
+		"Commit: " + shortSHA(run.HeadSha),
109
+	}
110
+	if run.Conclusion.Valid {
111
+		parts = append(parts, "Conclusion: "+string(run.Conclusion.CheckConclusion))
112
+	}
113
+	parts = append(parts, "Run: #"+strconv.FormatInt(run.RunIndex, 10))
114
+	return strings.Join(parts, "\n")
115
+}
116
+
117
+func actionsAtomActor(username string) string {
118
+	username = strings.TrimSpace(username)
119
+	if username == "" {
120
+		return "shithub"
121
+	}
122
+	return username
123
+}
internal/web/handlers/repo/actions_atom_test.goadded
79 lines changed — click to load
@@ -0,0 +1,79 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"bytes"
7
+	"encoding/xml"
8
+	"strings"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+)
16
+
17
+func TestWriteActionsAtomEscapesRunsAndUsesLatestUpdate(t *testing.T) {
18
+	ts1 := pgtype.Timestamptz{Time: time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC), Valid: true}
19
+	ts2 := pgtype.Timestamptz{Time: time.Date(2026, 5, 12, 9, 0, 0, 0, time.UTC), Valid: true}
20
+	var buf bytes.Buffer
21
+	writeActionsAtom(&buf, "alice", "demo", []actionsdb.ListWorkflowRunsForRepoRow{
22
+		{
23
+			ID:            12,
24
+			RunIndex:      7,
25
+			WorkflowFile:  ".shithub/workflows/ci.yml",
26
+			WorkflowName:  "CI <release>",
27
+			HeadSha:       strings.Repeat("a", 40),
28
+			HeadRef:       "refs/heads/trunk",
29
+			Event:         actionsdb.WorkflowRunEventPush,
30
+			Status:        actionsdb.WorkflowRunStatusCompleted,
31
+			Conclusion:    actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusionSuccess, Valid: true},
32
+			ActorUsername: "dev<one>",
33
+			CreatedAt:     ts2,
34
+			UpdatedAt:     ts1,
35
+		},
36
+	}, time.Date(2026, 5, 12, 8, 0, 0, 0, time.UTC))
37
+
38
+	type feed struct {
39
+		XMLName xml.Name `xml:"feed"`
40
+		Title   string   `xml:"title"`
41
+		Updated string   `xml:"updated"`
42
+		Entries []struct {
43
+			Title   string `xml:"title"`
44
+			Updated string `xml:"updated"`
45
+			Author  struct {
46
+				Name string `xml:"name"`
47
+			} `xml:"author"`
48
+			Summary string `xml:"summary"`
49
+			Link    struct {
50
+				Href string `xml:"href,attr"`
51
+			} `xml:"link"`
52
+		} `xml:"entry"`
53
+	}
54
+	var got feed
55
+	if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
56
+		t.Fatalf("unmarshal atom: %v\n%s", err, buf.String())
57
+	}
58
+	if got.Title != "alice/demo Actions runs" {
59
+		t.Fatalf("title = %q", got.Title)
60
+	}
61
+	if got.Updated != "2026-05-12T10:00:00Z" {
62
+		t.Fatalf("updated = %q", got.Updated)
63
+	}
64
+	if len(got.Entries) != 1 {
65
+		t.Fatalf("entries = %d", len(got.Entries))
66
+	}
67
+	if got.Entries[0].Title != "CI <release> #7 success" {
68
+		t.Fatalf("entry title = %q", got.Entries[0].Title)
69
+	}
70
+	if got.Entries[0].Author.Name != "dev<one>" {
71
+		t.Fatalf("author = %q", got.Entries[0].Author.Name)
72
+	}
73
+	if got.Entries[0].Link.Href != "/alice/demo/actions/runs/7" {
74
+		t.Fatalf("link = %q", got.Entries[0].Link.Href)
75
+	}
76
+	if !strings.Contains(got.Entries[0].Summary, "Conclusion: success") {
77
+		t.Fatalf("summary = %q", got.Entries[0].Summary)
78
+	}
79
+}
internal/web/handlers/repo/actions_log_stream.gomodified
13 lines changed — click to load
@@ -117,6 +117,13 @@ func (h *Handlers) repoActionStepLogStream(w http.ResponseWriter, r *http.Reques
117117
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
118118
 		return
119119
 	}
120
+	defer func() {
121
+		ctx, cancel := context.WithTimeout(context.Background(), actionsLogStreamReleaseTimeout)
122
+		defer cancel()
123
+		if _, err := conn.Exec(ctx, logstream.UnlistenSQL(step.ID)); err != nil && h.d.Logger != nil {
124
+			h.d.Logger.WarnContext(ctx, "repo actions: unlisten log stream", "step_id", step.ID, "error", err)
125
+		}
126
+	}()
120127
 
121128
 	w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
122129
 	w.Header().Set("Cache-Control", "no-cache, no-transform")
internal/web/handlers/repo/branches.gomodified
110 lines changed — click to load
@@ -22,6 +22,7 @@ import (
2222
 func (h *Handlers) MountRefs(r chi.Router) {
2323
 	r.Get("/{owner}/{repo}/branches", h.branchesList)
2424
 	r.Get("/{owner}/{repo}/tags", h.tagsList)
25
+	r.Get("/{owner}/{repo}/compare", h.compareView)
2526
 	// Compare uses `...` as the base/head separator (matches GitHub).
2627
 	// chi can't represent the literal `...` in a route param so we use
2728
 	// a wildcard and parse server-side.
@@ -209,21 +210,26 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) {
209210
 		return
210211
 	}
211212
 	rest := strings.Trim(chi.URLParam(r, "*"), "/")
212
-	if rest == "" {
213
-		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare/"+row.DefaultBranch+"..."+row.DefaultBranch, http.StatusSeeOther)
214
-		return
215
-	}
216
-	base, head, ok := strings.Cut(rest, "...")
217
-	if !ok {
218
-		// Two-dot shape — accept but treat as three-dot for the diff.
219
-		base, head, ok = strings.Cut(rest, "..")
220
-		if !ok {
221
-			head = rest
213
+	hasSelection := rest != ""
214
+	base := row.DefaultBranch
215
+	head := row.DefaultBranch
216
+	if hasSelection {
217
+		var found bool
218
+		base, head, found = strings.Cut(rest, "...")
219
+		if !found {
220
+			// Two-dot shape — accept but treat as three-dot for the diff.
221
+			base, head, found = strings.Cut(rest, "..")
222
+			if !found {
223
+				head = rest
224
+				base = row.DefaultBranch
225
+			}
226
+		}
227
+		if base == "" {
222228
 			base = row.DefaultBranch
223229
 		}
224
-	}
225
-	if base == "" {
226
-		base = row.DefaultBranch
230
+		if head == "" {
231
+			head = row.DefaultBranch
232
+		}
227233
 	}
228234
 
229235
 	// Strip cross-repo "fork:branch" prefix for the local path; full
@@ -231,37 +237,33 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) {
231237
 	base = stripCrossRepoPrefix(base)
232238
 	head = stripCrossRepoPrefix(head)
233239
 
234
-	commits, cerr := repogit.CommitsBetween(r.Context(), gitDir, base, head, 250)
235
-	ahead, behind, abErr := repogit.AheadBehind(r.Context(), gitDir, base, head)
236
-
237
-	notFound := abErr != nil
238
-
239
-	// Build an inline diff (three-dot via FromMergeBase).
240
-	var diffHTML string
241
-	if !notFound {
242
-		patch, perr := compareSourceMergeBase(r, gitDir, base, head)
243
-		if perr == nil {
244
-			diffHTML = renderCompareDiff(patch)
245
-		}
246
-	}
247
-
248
-	refs, _ := repogit.ListRefs(r.Context(), gitDir)
249
-	h.d.Render.RenderPage(w, r, "repo/compare", map[string]any{
250
-		"Title":      "Compare · " + row.Name,
251
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
252
-		"Owner":      owner.Username,
253
-		"Repo":       row,
254
-		"Base":       base,
255
-		"Head":       head,
256
-		"Ahead":      ahead,
257
-		"Behind":     behind,
258
-		"Commits":    commits,
259
-		"DiffHTML":   diffHTML,
260
-		"NotFound":   notFound,
261
-		"CommitsErr": cerr != nil,
262
-		"Branches":   refs.Branches,
263
-		"Tags":       refs.Tags,
264
-	})
240
+	state := h.buildCompareState(r, owner.Username, row, gitDir, base, head, hasSelection, compareMenuTargetCompare)
241
+	h.d.Render.RenderPage(w, r, "repo/compare", mergePageData(
242
+		h.repoPageChrome(r, owner.Username, row, "code"),
243
+		map[string]any{
244
+			"Title":        "Compare · " + row.Name,
245
+			"UseCompareJS": true,
246
+			"Compare":      state,
247
+			"Base":         state.Base,
248
+			"Head":         state.Head,
249
+			"HasSelection": state.HasSelection,
250
+			"SameRef":      state.SameRef,
251
+			"NotFound":     state.NotFound,
252
+			"CommitsErr":   state.CommitsErr,
253
+			"NoCommits":    state.NoCommits,
254
+			"Ahead":        state.Ahead,
255
+			"Behind":       state.Behind,
256
+			"Commits":      state.Commits,
257
+			"DiffHTML":     state.DiffHTML,
258
+			"Stats":        state.Stats,
259
+			"MergeState":   state.MergeState,
260
+			"CanOpenPull":  state.CanOpenPull,
261
+			"PullNewHref":  state.PullNewHref,
262
+			"BaseMenu":     state.BaseMenu,
263
+			"HeadMenu":     state.HeadMenu,
264
+			"Examples":     state.Examples,
265
+		},
266
+	))
265267
 }
266268
 
267269
 // stripCrossRepoPrefix turns "fork:branch" into "branch". Local-only
internal/web/handlers/repo/code.gomodified
57 lines changed — click to load
@@ -4,6 +4,7 @@ package repo
44
 
55
 import (
66
 	"bytes"
7
+	"context"
78
 	"errors"
89
 	"fmt"
910
 	"html/template"
@@ -66,6 +67,17 @@ type codeContext struct {
6667
 	subpath string // path inside the ref, no leading slash
6768
 }
6869
 
70
+type codeBranchCompare struct {
71
+	Show          bool
72
+	HasRecentPush bool
73
+	Base          string
74
+	Head          string
75
+	Ahead         int
76
+	Behind        int
77
+	CompareHref   string
78
+	PullNewHref   string
79
+}
80
+
6981
 // loadCodeContext does the resolve dance for tree/blob/raw/find. On
7082
 // any failure it writes the response and returns ok=false.
7183
 func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*codeContext, bool) {
@@ -132,6 +144,26 @@ func (cc *codeContext) isBranchRef() bool {
132144
 	return false
133145
 }
134146
 
147
+func codeBranchCompareData(ctx context.Context, cc *codeContext) codeBranchCompare {
148
+	if !cc.isBranchRef() || cc.ref == cc.row.DefaultBranch {
149
+		return codeBranchCompare{}
150
+	}
151
+	ahead, behind, err := repogit.AheadBehind(ctx, cc.gitDir, cc.row.DefaultBranch, cc.ref)
152
+	if err != nil {
153
+		return codeBranchCompare{}
154
+	}
155
+	return codeBranchCompare{
156
+		Show:          true,
157
+		HasRecentPush: ahead > 0,
158
+		Base:          cc.row.DefaultBranch,
159
+		Head:          cc.ref,
160
+		Ahead:         ahead,
161
+		Behind:        behind,
162
+		CompareHref:   compareURL(cc.owner, cc.row.Name, cc.row.DefaultBranch, cc.ref),
163
+		PullNewHref:   pullNewURL(cc.owner, cc.row.Name, cc.row.DefaultBranch, cc.ref),
164
+	}
165
+}
166
+
135167
 func (h *Handlers) canWriteRepo(r *http.Request, row reposdb.Repo) bool {
136168
 	viewer := middleware.CurrentUserFromContext(r.Context())
137169
 	if viewer.IsAnonymous() {
@@ -219,6 +251,7 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co
219251
 		"Head":          head,
220252
 		"HeadFound":     headFound,
221253
 		"HeadAuthor":    headAuthor,
254
+		"BranchCompare": codeBranchCompareData(r.Context(), cc),
222255
 		"CommitCount":   commitCount,
223256
 		"README":        template.HTML(readme.HTML), //nolint:gosec // sanitized by mdrender
224257
 		"READMEPath":    readme.Path,
internal/web/handlers/repo/compare_ui.goadded
361 lines changed — click to load
@@ -0,0 +1,361 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"net/http"
9
+	"net/url"
10
+	"strings"
11
+
12
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
13
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
15
+)
16
+
17
+type compareMenuTarget string
18
+
19
+const (
20
+	compareMenuTargetCompare compareMenuTarget = "compare"
21
+	compareMenuTargetPullNew compareMenuTarget = "pull_new"
22
+)
23
+
24
+type compareRefOption struct {
25
+	Name      string
26
+	Href      string
27
+	Current   bool
28
+	IsDefault bool
29
+}
30
+
31
+type compareRefMenu struct {
32
+	ID      string
33
+	Label   string
34
+	Title   string
35
+	Current string
36
+
37
+	Branches []compareRefOption
38
+	Tags     []compareRefOption
39
+}
40
+
41
+type compareExample struct {
42
+	Name string
43
+	Href string
44
+}
45
+
46
+type compareStats struct {
47
+	CommitCount      int
48
+	FileCount        int
49
+	ContributorCount int
50
+}
51
+
52
+type compareMergeState struct {
53
+	State       string
54
+	Label       string
55
+	Description string
56
+}
57
+
58
+type compareState struct {
59
+	Base         string
60
+	Head         string
61
+	HasSelection bool
62
+	SameRef      bool
63
+	NotFound     bool
64
+	CommitsErr   bool
65
+	NoCommits    bool
66
+	Ahead        int
67
+	Behind       int
68
+
69
+	Commits     []repogit.Commit
70
+	DiffHTML    string
71
+	Stats       compareStats
72
+	MergeState  compareMergeState
73
+	CanOpenPull bool
74
+	PullNewHref string
75
+
76
+	BaseMenu compareRefMenu
77
+	HeadMenu compareRefMenu
78
+	Examples []compareExample
79
+}
80
+
81
+func (h *Handlers) repoPageChrome(r *http.Request, owner string, row reposdb.Repo, activeSubnav string) map[string]any {
82
+	return map[string]any{
83
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
84
+		"Owner":        owner,
85
+		"Repo":         row,
86
+		"RepoActions":  h.repoActions(r, row.ID),
87
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
88
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
89
+		"ActiveSubnav": activeSubnav,
90
+	}
91
+}
92
+
93
+func mergePageData(base map[string]any, extra map[string]any) map[string]any {
94
+	out := make(map[string]any, len(base)+len(extra))
95
+	for k, v := range base {
96
+		out[k] = v
97
+	}
98
+	for k, v := range extra {
99
+		out[k] = v
100
+	}
101
+	return out
102
+}
103
+
104
+func (h *Handlers) buildCompareState(r *http.Request, owner string, row reposdb.Repo, gitDir, base, head string, hasSelection bool, target compareMenuTarget) compareState {
105
+	if strings.TrimSpace(base) == "" {
106
+		base = row.DefaultBranch
107
+	}
108
+	if strings.TrimSpace(head) == "" {
109
+		head = row.DefaultBranch
110
+	}
111
+
112
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
113
+	state := compareState{
114
+		Base:         base,
115
+		Head:         head,
116
+		HasSelection: hasSelection,
117
+		SameRef:      base == head,
118
+		PullNewHref:  pullNewURL(owner, row.Name, base, head),
119
+		MergeState: compareMergeState{
120
+			State:       "pending",
121
+			Label:       "Checking mergeability...",
122
+			Description: "You can still create the pull request while shithub checks these branches.",
123
+		},
124
+	}
125
+	state.BaseMenu, state.HeadMenu = buildCompareMenus(owner, row.Name, row.DefaultBranch, base, head, refs, target)
126
+	state.Examples = buildCompareExamples(owner, row.Name, row.DefaultBranch, refs)
127
+
128
+	if !hasSelection || base == "" || head == "" {
129
+		state.MergeState = compareMergeState{}
130
+		return state
131
+	}
132
+
133
+	commits, cerr := repogit.CommitsBetween(r.Context(), gitDir, base, head, 250)
134
+	if cerr != nil {
135
+		state.CommitsErr = true
136
+	}
137
+	state.Commits = commits
138
+
139
+	ahead, behind, abErr := repogit.AheadBehind(r.Context(), gitDir, base, head)
140
+	if abErr != nil {
141
+		state.NotFound = true
142
+		state.MergeState = compareMergeState{
143
+			State:       "missing",
144
+			Label:       "There was a problem comparing these refs.",
145
+			Description: "One or both refs were not found in this repository.",
146
+		}
147
+		return state
148
+	}
149
+	state.Ahead = ahead
150
+	state.Behind = behind
151
+	state.NoCommits = ahead <= 0
152
+	state.Stats.CommitCount = len(commits)
153
+	state.Stats.ContributorCount = countCommitContributors(commits)
154
+
155
+	if state.SameRef {
156
+		state.MergeState = compareMergeState{}
157
+		return state
158
+	}
159
+	if state.NoCommits {
160
+		state.MergeState = compareMergeState{
161
+			State:       "empty",
162
+			Label:       "There isn't anything to compare.",
163
+			Description: head + " is up to date with " + base + ".",
164
+		}
165
+		return state
166
+	}
167
+
168
+	patch, perr := compareSourceMergeBase(r, gitDir, base, head)
169
+	if perr == nil {
170
+		state.DiffHTML = renderCompareDiff(patch)
171
+		state.Stats.FileCount = countPatchFiles(patch)
172
+	}
173
+	state.CanOpenPull = true
174
+	state.MergeState = probeCompareMerge(r.Context(), gitDir, base, head)
175
+	return state
176
+}
177
+
178
+func buildCompareMenus(owner, repo, defaultBranch, base, head string, refs repogit.RefListing, target compareMenuTarget) (compareRefMenu, compareRefMenu) {
179
+	baseMenu := compareRefMenu{
180
+		ID:      "base",
181
+		Label:   "base:",
182
+		Title:   "Choose a base ref",
183
+		Current: base,
184
+	}
185
+	headMenu := compareRefMenu{
186
+		ID:      "head",
187
+		Label:   "compare:",
188
+		Title:   "Choose a head ref",
189
+		Current: head,
190
+	}
191
+
192
+	baseMenu.Branches = compareRefOptions(owner, repo, defaultBranch, base, head, base, refs.Branches, target, true)
193
+	headMenu.Branches = compareRefOptions(owner, repo, defaultBranch, base, head, head, refs.Branches, target, false)
194
+	baseMenu.Tags = compareRefOptions(owner, repo, defaultBranch, base, head, base, refs.Tags, target, true)
195
+	headMenu.Tags = compareRefOptions(owner, repo, defaultBranch, base, head, head, refs.Tags, target, false)
196
+
197
+	baseMenu.Branches = ensureCompareRefOption(baseMenu.Branches, owner, repo, defaultBranch, base, head, base, target, true)
198
+	headMenu.Branches = ensureCompareRefOption(headMenu.Branches, owner, repo, defaultBranch, base, head, head, target, false)
199
+	return baseMenu, headMenu
200
+}
201
+
202
+func compareRefOptions(owner, repo, defaultBranch, base, head, current string, refs []repogit.RefEntry, target compareMenuTarget, changingBase bool) []compareRefOption {
203
+	options := make([]compareRefOption, 0, len(refs))
204
+	for _, ref := range refs {
205
+		options = append(options, compareRefOption{
206
+			Name:      ref.Name,
207
+			Href:      compareRefHref(owner, repo, base, head, ref.Name, target, changingBase),
208
+			Current:   ref.Name == current,
209
+			IsDefault: ref.Name == defaultBranch,
210
+		})
211
+	}
212
+	return options
213
+}
214
+
215
+func ensureCompareRefOption(options []compareRefOption, owner, repo, defaultBranch, base, head, current string, target compareMenuTarget, changingBase bool) []compareRefOption {
216
+	if current == "" {
217
+		return options
218
+	}
219
+	for _, option := range options {
220
+		if option.Name == current {
221
+			return options
222
+		}
223
+	}
224
+	return append([]compareRefOption{{
225
+		Name:      current,
226
+		Href:      compareRefHref(owner, repo, base, head, current, target, changingBase),
227
+		Current:   true,
228
+		IsDefault: current == defaultBranch,
229
+	}}, options...)
230
+}
231
+
232
+func compareRefHref(owner, repo, base, head, ref string, target compareMenuTarget, changingBase bool) string {
233
+	if changingBase {
234
+		base = ref
235
+	} else {
236
+		head = ref
237
+	}
238
+	if target == compareMenuTargetPullNew {
239
+		return pullNewURL(owner, repo, base, head)
240
+	}
241
+	return compareURL(owner, repo, base, head)
242
+}
243
+
244
+func buildCompareExamples(owner, repo, defaultBranch string, refs repogit.RefListing) []compareExample {
245
+	examples := make([]compareExample, 0, 5)
246
+	for _, branch := range refs.Branches {
247
+		if branch.Name == defaultBranch {
248
+			continue
249
+		}
250
+		examples = append(examples, compareExample{
251
+			Name: branch.Name,
252
+			Href: compareURL(owner, repo, defaultBranch, branch.Name),
253
+		})
254
+		if len(examples) == 5 {
255
+			break
256
+		}
257
+	}
258
+	return examples
259
+}
260
+
261
+func compareURL(owner, repo, base, head string) string {
262
+	return "/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/compare/" + escapePathSegments(base) + "..." + escapePathSegments(head)
263
+}
264
+
265
+func pullNewURL(owner, repo, base, head string) string {
266
+	q := url.Values{}
267
+	q.Set("base", base)
268
+	q.Set("head", head)
269
+	return "/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls/new?" + q.Encode()
270
+}
271
+
272
+func countPatchFiles(patch []byte) int {
273
+	if len(patch) == 0 {
274
+		return 0
275
+	}
276
+	count := 0
277
+	for _, line := range strings.Split(string(patch), "\n") {
278
+		if strings.HasPrefix(line, "diff --git ") {
279
+			count++
280
+		}
281
+	}
282
+	return count
283
+}
284
+
285
+func countCommitContributors(commits []repogit.Commit) int {
286
+	if len(commits) == 0 {
287
+		return 0
288
+	}
289
+	seen := map[string]struct{}{}
290
+	for _, commit := range commits {
291
+		key := strings.ToLower(strings.TrimSpace(commit.AuthorEmail))
292
+		if key == "" {
293
+			key = strings.ToLower(strings.TrimSpace(commit.AuthorName))
294
+		}
295
+		if key != "" {
296
+			seen[key] = struct{}{}
297
+		}
298
+	}
299
+	return len(seen)
300
+}
301
+
302
+func defaultPullTitle(head string, commits []repogit.Commit) string {
303
+	if len(commits) == 1 && strings.TrimSpace(commits[0].Subject) != "" {
304
+		return commits[0].Subject
305
+	}
306
+	if strings.TrimSpace(head) == "" {
307
+		return ""
308
+	}
309
+	return head
310
+}
311
+
312
+func probeCompareMerge(ctx context.Context, gitDir, base, head string) compareMergeState {
313
+	baseOID, berr := repogit.ResolveRefOID(ctx, gitDir, base)
314
+	headOID, herr := repogit.ResolveRefOID(ctx, gitDir, head)
315
+	if berr != nil || herr != nil {
316
+		return compareMergeState{
317
+			State:       "missing",
318
+			Label:       "Unable to check mergeability.",
319
+			Description: "One or both refs could not be resolved.",
320
+		}
321
+	}
322
+	result, err := repogit.ProbeMerge(ctx, gitDir, baseOID, headOID)
323
+	if err != nil {
324
+		if errors.Is(err, repogit.ErrRefNotFound) {
325
+			return compareMergeState{
326
+				State:       "missing",
327
+				Label:       "Unable to check mergeability.",
328
+				Description: "One or both refs could not be resolved.",
329
+			}
330
+		}
331
+		return compareMergeState{
332
+			State:       "unknown",
333
+			Label:       "Mergeability could not be checked.",
334
+			Description: "You can still create the pull request and shithub will retry the check.",
335
+		}
336
+	}
337
+	if result.HasConflict {
338
+		return compareMergeState{
339
+			State:       "conflict",
340
+			Label:       "Cannot automatically merge.",
341
+			Description: "These branches have conflicts that must be resolved.",
342
+		}
343
+	}
344
+	return compareMergeState{
345
+		State:       "clean",
346
+		Label:       "Able to merge.",
347
+		Description: "These branches can be automatically merged.",
348
+	}
349
+}
350
+
351
+func pullNewCommentEditorConfig(viewer middleware.CurrentUser) commentEditorConfig {
352
+	if viewer.IsAnonymous() || strings.EqualFold(viewer.Username, "copilot") {
353
+		return commentEditorConfig{}
354
+	}
355
+	return commentEditorConfig{
356
+		Mentions: []commentEditorMention{{
357
+			Username:  viewer.Username,
358
+			AvatarURL: commentEditorAvatarURL(viewer.Username),
359
+		}},
360
+	}
361
+}
internal/web/handlers/repo/compare_ui_test.goadded
65 lines changed — click to load
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"testing"
7
+
8
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
9
+)
10
+
11
+func TestCompareURLsEscapeBranchSegments(t *testing.T) {
12
+	got := compareURL("tenseleyFlow", "shithub", "trunk", "feature/a b")
13
+	want := "/tenseleyFlow/shithub/compare/trunk...feature/a%20b"
14
+	if got != want {
15
+		t.Fatalf("compareURL() = %q, want %q", got, want)
16
+	}
17
+
18
+	got = pullNewURL("tenseleyFlow", "shithub", "trunk", "feature/a b")
19
+	want = "/tenseleyFlow/shithub/pulls/new?base=trunk&head=feature%2Fa+b"
20
+	if got != want {
21
+		t.Fatalf("pullNewURL() = %q, want %q", got, want)
22
+	}
23
+}
24
+
25
+func TestBuildCompareMenusPreservesOtherSide(t *testing.T) {
26
+	refs := repogit.RefListing{
27
+		Branches: []repogit.RefEntry{
28
+			{Name: "trunk"},
29
+			{Name: "scratch"},
30
+		},
31
+		Tags: []repogit.RefEntry{{Name: "v1.0.0"}},
32
+	}
33
+
34
+	baseMenu, headMenu := buildCompareMenus("octo", "demo", "trunk", "trunk", "scratch", refs, compareMenuTargetCompare)
35
+	if baseMenu.Branches[1].Href != "/octo/demo/compare/scratch...scratch" {
36
+		t.Fatalf("base branch href = %q", baseMenu.Branches[1].Href)
37
+	}
38
+	if headMenu.Branches[0].Href != "/octo/demo/compare/trunk...trunk" {
39
+		t.Fatalf("head branch href = %q", headMenu.Branches[0].Href)
40
+	}
41
+	if !baseMenu.Branches[0].IsDefault {
42
+		t.Fatalf("default branch not marked")
43
+	}
44
+
45
+	_, pullHeadMenu := buildCompareMenus("octo", "demo", "trunk", "trunk", "scratch", refs, compareMenuTargetPullNew)
46
+	if pullHeadMenu.Branches[1].Href != "/octo/demo/pulls/new?base=trunk&head=scratch" {
47
+		t.Fatalf("pull new head href = %q", pullHeadMenu.Branches[1].Href)
48
+	}
49
+}
50
+
51
+func TestCountPatchFiles(t *testing.T) {
52
+	patch := []byte(`diff --git a/one.txt b/one.txt
53
+index 1111111..2222222 100644
54
+--- a/one.txt
55
++++ b/one.txt
56
+@@ -1 +1 @@
57
+-old
58
++new
59
+diff --git a/two.txt b/two.txt
60
+new file mode 100644
61
+`)
62
+	if got := countPatchFiles(patch); got != 2 {
63
+		t.Fatalf("countPatchFiles() = %d, want 2", got)
64
+	}
65
+}
internal/web/handlers/repo/pulls.gomodified
120 lines changed — click to load
@@ -190,17 +190,87 @@ func (h *Handlers) pullNewForm(w http.ResponseWriter, r *http.Request) {
190190
 		base = row.DefaultBranch
191191
 	}
192192
 	head := r.URL.Query().Get("head")
193
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
194
-	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{
195
-		"Title":     "New pull request · " + row.Name,
196
-		"Owner":     owner.Username,
197
-		"Repo":      row,
198
-		"Base":      base,
199
-		"Head":      head,
200
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
193
+	if strings.TrimSpace(head) == "" {
194
+		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare", http.StatusSeeOther)
195
+		return
196
+	}
197
+	h.renderPullNewForm(w, r, owner.Username, row, pullNewFormOptions{
198
+		Base: base,
199
+		Head: head,
201200
 	})
202201
 }
203202
 
203
+type pullNewFormOptions struct {
204
+	Base      string
205
+	Head      string
206
+	FormTitle string
207
+	FormBody  string
208
+	Error     string
209
+	Status    int
210
+}
211
+
212
+func (h *Handlers) renderPullNewForm(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, opts pullNewFormOptions) {
213
+	gitDir, err := h.d.RepoFS.RepoPath(owner, row.Name)
214
+	if err != nil {
215
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
216
+		return
217
+	}
218
+	base := strings.TrimSpace(opts.Base)
219
+	if base == "" {
220
+		base = row.DefaultBranch
221
+	}
222
+	head := strings.TrimSpace(opts.Head)
223
+	if head == "" {
224
+		head = row.DefaultBranch
225
+	}
226
+	state := h.buildCompareState(r, owner, row, gitDir, base, head, true, compareMenuTargetPullNew)
227
+	formTitle := opts.FormTitle
228
+	if strings.TrimSpace(formTitle) == "" && opts.Error == "" {
229
+		formTitle = defaultPullTitle(state.Head, state.Commits)
230
+	}
231
+	viewer := middleware.CurrentUserFromContext(r.Context())
232
+	status := opts.Status
233
+	if status == 0 {
234
+		status = http.StatusOK
235
+	}
236
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
237
+	if status != http.StatusOK {
238
+		w.WriteHeader(status)
239
+	}
240
+	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", mergePageData(
241
+		h.repoPageChrome(r, owner, row, "pulls"),
242
+		map[string]any{
243
+			"Title":               "Open a pull request · " + row.Name,
244
+			"UseCompareJS":        true,
245
+			"UseCommentEditor":    true,
246
+			"CommentEditorConfig": commentEditorConfigJSON(pullNewCommentEditorConfig(viewer)),
247
+			"Viewer":              viewer,
248
+			"ViewerAvatarURL":     commentEditorAvatarURL(viewer.Username),
249
+			"Error":               opts.Error,
250
+			"FormTitle":           formTitle,
251
+			"FormBody":            opts.FormBody,
252
+			"Base":                state.Base,
253
+			"Head":                state.Head,
254
+			"HasSelection":        state.HasSelection,
255
+			"SameRef":             state.SameRef,
256
+			"NotFound":            state.NotFound,
257
+			"CommitsErr":          state.CommitsErr,
258
+			"NoCommits":           state.NoCommits,
259
+			"Ahead":               state.Ahead,
260
+			"Behind":              state.Behind,
261
+			"Commits":             state.Commits,
262
+			"DiffHTML":            state.DiffHTML,
263
+			"Stats":               state.Stats,
264
+			"MergeState":          state.MergeState,
265
+			"CanOpenPull":         state.CanOpenPull,
266
+			"CanCreatePull":       state.CanOpenPull && !state.NotFound && !state.CommitsErr,
267
+			"PullNewHref":         state.PullNewHref,
268
+			"BaseMenu":            state.BaseMenu,
269
+			"HeadMenu":            state.HeadMenu,
270
+		},
271
+	))
272
+}
273
+
204274
 // pullCreate handles POST /{owner}/{repo}/pulls.
205275
 func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) {
206276
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
@@ -266,18 +336,13 @@ func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request,
266336
 	case errors.Is(err, issues.ErrBodyTooLong):
267337
 		msg = "Body is too long."
268338
 	}
269
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
270
-	w.WriteHeader(http.StatusBadRequest)
271
-	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{
272
-		"Title":     "New pull request · " + row.Name,
273
-		"Owner":     owner,
274
-		"Repo":      row,
275
-		"Base":      r.PostFormValue("base"),
276
-		"Head":      r.PostFormValue("head"),
277
-		"FormTitle": r.PostFormValue("title"),
278
-		"FormBody":  r.PostFormValue("body"),
279
-		"Error":     msg,
280
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
339
+	h.renderPullNewForm(w, r, owner, row, pullNewFormOptions{
340
+		Base:      r.PostFormValue("base"),
341
+		Head:      r.PostFormValue("head"),
342
+		FormTitle: r.PostFormValue("title"),
343
+		FormBody:  r.PostFormValue("body"),
344
+		Error:     msg,
345
+		Status:    http.StatusBadRequest,
281346
 	})
282347
 }
283348
 
internal/web/handlers/repo/repo.gomodified
19 lines changed — click to load
@@ -133,12 +133,18 @@ func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
133133
 	r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
134134
 }
135135
 
136
+// MountRepoActionsStreams registers long-lived Actions streaming routes.
137
+// Caller must mount this outside compression and request-timeout middleware.
138
+func (h *Handlers) MountRepoActionsStreams(r chi.Router) {
139
+	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", h.repoActionStepLogStream)
140
+}
141
+
136142
 // MountRepoHome registers the root repository route plus product-tab shells
137143
 // that are intentionally public and read-gated like the Code tab. The
138144
 // two-segment route doesn't collide with the /{username} catch-all from S09;
139145
 // caller is responsible for ordering this BEFORE /{username}.
140146
 func (h *Handlers) MountRepoHome(r chi.Router) {
141
-	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", h.repoActionStepLogStream)
147
+	r.Get("/{owner}/{repo}/actions.atom", h.repoActionsAtom)
142148
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
143149
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
144150
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)
internal/web/middleware/middleware_test.gomodified
23 lines changed — click to load
@@ -61,6 +61,23 @@ func TestOptionalUser_PopulatesIsSuspended(t *testing.T) {
6161
 	}
6262
 }
6363
 
64
+func TestPATAuthPolicyActorPropagatesResolvedUserFlags(t *testing.T) {
65
+	t.Parallel()
66
+
67
+	actor := PATAuth{
68
+		UserID:      42,
69
+		Username:    "alice",
70
+		IsSuspended: true,
71
+		IsSiteAdmin: true,
72
+	}.PolicyActor()
73
+	if actor.UserID != 42 || actor.Username != "alice" || !actor.IsSuspended || !actor.IsSiteAdmin {
74
+		t.Fatalf("PolicyActor did not propagate PAT user flags: %+v", actor)
75
+	}
76
+	if anon := (PATAuth{}).PolicyActor(); !anon.IsAnonymous {
77
+		t.Fatalf("zero PATAuth should produce anonymous actor: %+v", anon)
78
+	}
79
+}
80
+
6481
 // TestOptionalUser_StaleEpochSkipsBind is the corollary: when the
6582
 // recorded session epoch doesn't match the current users.session_epoch
6683
 // (because the user logged out everywhere), the binding is skipped so
internal/web/middleware/pat.gomodified
148 lines changed — click to load
@@ -15,6 +15,7 @@ import (
1515
 	"github.com/jackc/pgx/v5/pgxpool"
1616
 
1717
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1819
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1920
 )
2021
 
@@ -24,9 +25,12 @@ var patAuthKey = ctxKey{name: "pat_auth"}
2425
 // the auth check passed via PAT, `Token != nil` and Scopes is the parsed
2526
 // scope list. Pure session callers see the zero value.
2627
 type PATAuth struct {
27
-	UserID  int64
28
-	TokenID int64
29
-	Scopes  []string
28
+	UserID      int64
29
+	Username    string
30
+	TokenID     int64
31
+	Scopes      []string
32
+	IsSuspended bool
33
+	IsSiteAdmin bool
3034
 }
3135
 
3236
 // PATAuthFromContext returns the resolved PAT auth state, or the zero
@@ -38,6 +42,14 @@ func PATAuthFromContext(ctx context.Context) PATAuth {
3842
 	return PATAuth{}
3943
 }
4044
 
45
+// PolicyActor returns the canonical policy actor for a resolved PAT request.
46
+func (p PATAuth) PolicyActor() policy.Actor {
47
+	if p.UserID == 0 {
48
+		return policy.AnonymousActor()
49
+	}
50
+	return policy.UserActor(p.UserID, p.Username, p.IsSuspended, p.IsSiteAdmin)
51
+}
52
+
4153
 // PATConfig configures the PAT auth middleware.
4254
 type PATConfig struct {
4355
 	Pool      *pgxpool.Pool
@@ -133,10 +145,19 @@ func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler {
133145
 				}()
134146
 			}
135147
 
148
+			// X-OAuth-Scopes echoes the token's scopes on every response —
149
+			// even errors emitted further down the chain — so the CLI's
150
+			// error path (shithub-cli/internal/api/errors.go) can report
151
+			// provided scopes alongside the required scope on 403.
152
+			w.Header().Set("X-OAuth-Scopes", strings.Join(row.Scopes, ", "))
153
+
136154
 			ctx := context.WithValue(r.Context(), patAuthKey, PATAuth{
137
-				UserID:  row.UserID,
138
-				TokenID: row.ID,
139
-				Scopes:  row.Scopes,
155
+				UserID:      row.UserID,
156
+				Username:    user.Username,
157
+				TokenID:     row.ID,
158
+				Scopes:      row.Scopes,
159
+				IsSuspended: user.SuspendedAt.Valid,
160
+				IsSiteAdmin: user.IsSiteAdmin,
140161
 			})
141162
 			next.ServeHTTP(w, r.WithContext(ctx))
142163
 		})
@@ -146,6 +167,11 @@ func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler {
146167
 // RequireScope rejects with 403 if the request was authenticated via PAT
147168
 // and the token's scopes don't include required. Pure-session callers
148169
 // (PATAuth zero) pass through — sessions have implicit full scope.
170
+//
171
+// The 403 response is the canonical /api/v1 JSON envelope
172
+// `{"error": "token lacks required scope: <scope>"}` and carries
173
+// X-Accepted-OAuth-Scopes so the CLI can format an actionable error
174
+// without parsing the message body.
149175
 func RequireScope(required pat.Scope) func(http.Handler) http.Handler {
150176
 	return func(next http.Handler) http.Handler {
151177
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -155,9 +181,8 @@ func RequireScope(required pat.Scope) func(http.Handler) http.Handler {
155181
 				return
156182
 			}
157183
 			if !pat.HasScope(auth.Scopes, required) {
158
-				w.Header().Set("Content-Type", "text/plain; charset=utf-8")
159
-				w.WriteHeader(http.StatusForbidden)
160
-				_, _ = w.Write([]byte("token lacks required scope: " + string(required) + "\n"))
184
+				w.Header().Set("X-Accepted-OAuth-Scopes", string(required))
185
+				writeAPIJSONError(w, http.StatusForbidden, "token lacks required scope: "+string(required))
161186
 				return
162187
 			}
163188
 			next.ServeHTTP(w, r)
@@ -165,6 +190,54 @@ func RequireScope(required pat.Scope) func(http.Handler) http.Handler {
165190
 	}
166191
 }
167192
 
193
+// writeAPIJSONError writes the canonical /api/v1 error envelope. Kept
194
+// inline so package middleware doesn't depend on the api handler package
195
+// (which would import-cycle).
196
+func writeAPIJSONError(w http.ResponseWriter, status int, msg string) {
197
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
198
+	w.Header().Set("Cache-Control", "no-store")
199
+	w.WriteHeader(status)
200
+	// Escape the inner message just enough for embedding in a JSON
201
+	// string literal. The reason strings here are package-controlled —
202
+	// no user input — but defensive escaping keeps this honest if a
203
+	// caller ever passes through external data.
204
+	_, _ = w.Write([]byte(`{"error":` + jsonString(msg) + `}` + "\n"))
205
+}
206
+
207
+// jsonString returns s wrapped in JSON-string quoting, escaping the
208
+// minimum required characters. Avoiding encoding/json here keeps
209
+// allocations down on the hot challenge path.
210
+func jsonString(s string) string {
211
+	var b strings.Builder
212
+	b.Grow(len(s) + 2)
213
+	b.WriteByte('"')
214
+	for _, r := range s {
215
+		switch r {
216
+		case '"', '\\':
217
+			b.WriteByte('\\')
218
+			b.WriteRune(r)
219
+		case '\n':
220
+			b.WriteString(`\n`)
221
+		case '\r':
222
+			b.WriteString(`\r`)
223
+		case '\t':
224
+			b.WriteString(`\t`)
225
+		default:
226
+			if r < 0x20 {
227
+				// Control chars: emit as \u00XX.
228
+				const hex = "0123456789abcdef"
229
+				b.WriteString(`\u00`)
230
+				b.WriteByte(hex[byte(r)>>4])
231
+				b.WriteByte(hex[byte(r)&0x0f])
232
+				continue
233
+			}
234
+			b.WriteRune(r)
235
+		}
236
+	}
237
+	b.WriteByte('"')
238
+	return b.String()
239
+}
240
+
168241
 // errNoCredentials is the sentinel that says "no Authorization header at
169242
 // all" — distinct from "Authorization present but malformed."
170243
 var errNoCredentials = errors.New("middleware: no credentials")
@@ -201,11 +274,11 @@ func extractPAT(r *http.Request) (string, error) {
201274
 }
202275
 
203276
 // writePATChallenge writes the canonical 401 with a Bearer challenge.
277
+// Body is the /api/v1 JSON error envelope so the shithub-cli client and
278
+// any other JSON consumer can decode the failure uniformly.
204279
 func writePATChallenge(w http.ResponseWriter, realm, reason string) {
205280
 	w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`", error="invalid_token", error_description="`+reason+`"`)
206
-	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
207
-	w.WriteHeader(http.StatusUnauthorized)
208
-	_, _ = w.Write([]byte(reason + "\n"))
281
+	writeAPIJSONError(w, http.StatusUnauthorized, reason)
209282
 }
210283
 
211284
 // remoteAddrFromRequest pulls the client IP for last_used_ip. Reuses the
internal/web/server.gomodified
7 lines changed — click to load
@@ -211,6 +211,7 @@ func Run(ctx context.Context, opts Options) error {
211211
 			})
212212
 		}
213213
 		deps.RepoHomeMounter = repoH.MountRepoHome
214
+		deps.RepoActionsStreamMounter = repoH.MountRepoActionsStreams
214215
 		deps.RepoCodeMounter = repoH.MountCode
215216
 		deps.RepoHistoryMounter = repoH.MountHistory
216217
 		deps.RepoRefsMounter = repoH.MountRefs
internal/web/static/css/shithub.cssmodified
508 lines changed — click to load
@@ -7877,9 +7877,484 @@ button.shithub-repo-action {
78777877
   border-bottom: 1px solid var(--border-default);
78787878
 }
78797879
 .shithub-branches-subject { color: var(--fg-default); }
7880
-.shithub-compare-summary { padding: 0.75rem 1rem; background: var(--canvas-subtle); border-radius: 6px; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
7881
-.shithub-compare-empty { padding: 1.5rem; text-align: center; color: var(--fg-muted); border: 1px dashed var(--border-default); border-radius: 6px; }
7882
-.shithub-compare-commits { margin-top: 1.5rem; }
7880
+.shithub-compare-flow {
7881
+  max-width: 62.5rem;
7882
+  margin: 1.5rem auto 3rem;
7883
+  padding: 0 1rem;
7884
+}
7885
+.shithub-compare-subhead {
7886
+  padding-bottom: 0.75rem;
7887
+  margin-bottom: 0.75rem;
7888
+  border-bottom: 1px solid var(--border-default);
7889
+}
7890
+.shithub-compare-subhead h1 {
7891
+  margin: 0 0 0.25rem;
7892
+  font-size: 1.5rem;
7893
+}
7894
+.shithub-compare-subhead p {
7895
+  margin: 0;
7896
+  color: var(--fg-muted);
7897
+}
7898
+.shithub-branch-push-banner,
7899
+.shithub-branch-compare-bar {
7900
+  display: flex;
7901
+  align-items: center;
7902
+  justify-content: space-between;
7903
+  gap: 1rem;
7904
+  margin-bottom: 0.75rem;
7905
+  padding: 0.75rem 1rem;
7906
+  border: 1px solid var(--border-default);
7907
+  border-radius: 6px;
7908
+}
7909
+.shithub-branch-push-banner {
7910
+  border-color: rgba(187, 128, 9, 0.45);
7911
+  background: rgba(187, 128, 9, 0.12);
7912
+}
7913
+.shithub-branch-push-banner > div,
7914
+.shithub-branch-compare-bar > div {
7915
+  display: flex;
7916
+  align-items: center;
7917
+  gap: 0.4rem;
7918
+  min-width: 0;
7919
+  flex-wrap: wrap;
7920
+}
7921
+.shithub-branch-compare-bar {
7922
+  background: var(--canvas-subtle);
7923
+}
7924
+.shithub-contribute-menu {
7925
+  position: relative;
7926
+  flex: 0 0 auto;
7927
+}
7928
+.shithub-contribute-menu > summary {
7929
+  list-style: none;
7930
+}
7931
+.shithub-contribute-menu > summary::-webkit-details-marker {
7932
+  display: none;
7933
+}
7934
+.shithub-contribute-menu-panel {
7935
+  position: absolute;
7936
+  z-index: 60;
7937
+  right: 0;
7938
+  top: calc(100% + 0.25rem);
7939
+  width: min(26rem, calc(100vw - 2rem));
7940
+  padding: 1rem;
7941
+  color: var(--fg-default);
7942
+  background: var(--canvas-overlay, var(--canvas-default));
7943
+  border: 1px solid var(--border-default);
7944
+  border-radius: 8px;
7945
+  box-shadow: var(--shadow-large, 0 16px 32px rgba(1, 4, 9, 0.35));
7946
+}
7947
+.shithub-contribute-menu-panel p {
7948
+  margin: 0.25rem 0 0;
7949
+  color: var(--fg-muted);
7950
+}
7951
+.shithub-contribute-actions {
7952
+  display: flex;
7953
+  justify-content: flex-end;
7954
+  gap: 0.5rem;
7955
+  margin-top: 1rem;
7956
+}
7957
+.shithub-range-editor {
7958
+  display: flex;
7959
+  align-items: center;
7960
+  gap: 0.5rem;
7961
+  flex-wrap: wrap;
7962
+  padding: 0.75rem 0;
7963
+}
7964
+.shithub-range-separator {
7965
+  color: var(--fg-muted);
7966
+  font-weight: 600;
7967
+}
7968
+.shithub-range-create {
7969
+  margin-left: auto;
7970
+}
7971
+.shithub-compare-ref-menu {
7972
+  position: relative;
7973
+}
7974
+.shithub-compare-ref-summary {
7975
+  display: inline-flex;
7976
+  align-items: center;
7977
+  gap: 0.35rem;
7978
+  list-style: none;
7979
+}
7980
+.shithub-compare-ref-summary::-webkit-details-marker {
7981
+  display: none;
7982
+}
7983
+.shithub-compare-ref-label {
7984
+  color: var(--fg-muted);
7985
+}
7986
+.shithub-compare-ref-current {
7987
+  color: var(--fg-default);
7988
+  font-weight: 600;
7989
+}
7990
+.shithub-compare-ref-panel {
7991
+  position: absolute;
7992
+  z-index: 70;
7993
+  top: calc(100% + 0.25rem);
7994
+  left: 0;
7995
+  width: min(20rem, calc(100vw - 2rem));
7996
+  overflow: hidden;
7997
+  background: var(--canvas-overlay, var(--canvas-default));
7998
+  border: 1px solid var(--border-default);
7999
+  border-radius: 8px;
8000
+  box-shadow: var(--shadow-large, 0 16px 32px rgba(1, 4, 9, 0.35));
8001
+}
8002
+.shithub-compare-ref-panel-head {
8003
+  display: flex;
8004
+  align-items: center;
8005
+  justify-content: space-between;
8006
+  gap: 0.75rem;
8007
+  padding: 0.75rem;
8008
+  border-bottom: 1px solid var(--border-default);
8009
+}
8010
+.shithub-compare-ref-filter {
8011
+  display: flex;
8012
+  align-items: center;
8013
+  gap: 0.4rem;
8014
+  margin: 0.5rem;
8015
+  padding: 0 0.5rem;
8016
+  border: 1px solid var(--border-default);
8017
+  border-radius: 6px;
8018
+  color: var(--fg-muted);
8019
+}
8020
+.shithub-compare-ref-filter input {
8021
+  width: 100%;
8022
+  min-width: 0;
8023
+  padding: 0.45rem 0;
8024
+  color: var(--fg-default);
8025
+  background: transparent;
8026
+  border: 0;
8027
+  outline: 0;
8028
+  font: inherit;
8029
+}
8030
+.shithub-compare-ref-tabs {
8031
+  display: flex;
8032
+  border-top: 1px solid var(--border-default);
8033
+  border-bottom: 1px solid var(--border-default);
8034
+}
8035
+.shithub-compare-ref-tabs button {
8036
+  flex: 1 1 0;
8037
+  padding: 0.55rem 0.75rem;
8038
+  color: var(--fg-muted);
8039
+  background: transparent;
8040
+  border: 0;
8041
+  border-bottom: 2px solid transparent;
8042
+  font: inherit;
8043
+  font-weight: 600;
8044
+  cursor: pointer;
8045
+}
8046
+.shithub-compare-ref-tabs button.is-active {
8047
+  color: var(--fg-default);
8048
+  border-bottom-color: var(--accent-emphasis);
8049
+}
8050
+.shithub-compare-ref-list {
8051
+  max-height: 19rem;
8052
+  overflow: auto;
8053
+}
8054
+.shithub-compare-ref-option {
8055
+  display: grid;
8056
+  grid-template-columns: 1.25rem minmax(0, 1fr) auto;
8057
+  align-items: center;
8058
+  gap: 0.4rem;
8059
+  padding: 0.55rem 0.75rem;
8060
+  color: var(--fg-default);
8061
+  border-bottom: 1px solid var(--border-default);
8062
+}
8063
+.shithub-compare-ref-option:hover,
8064
+.shithub-compare-ref-option.is-current {
8065
+  color: #ffffff;
8066
+  text-decoration: none;
8067
+  background: var(--accent-emphasis);
8068
+}
8069
+.shithub-compare-ref-check {
8070
+  display: inline-flex;
8071
+  color: currentColor;
8072
+}
8073
+.shithub-compare-ref-option-name {
8074
+  overflow: hidden;
8075
+  text-overflow: ellipsis;
8076
+  white-space: nowrap;
8077
+}
8078
+.shithub-compare-ref-default {
8079
+  padding: 0.05rem 0.35rem;
8080
+  color: var(--fg-default);
8081
+  border: 1px solid var(--border-default);
8082
+  border-radius: 999px;
8083
+  font-size: 0.75rem;
8084
+  font-weight: 600;
8085
+}
8086
+.shithub-compare-ref-option:hover .shithub-compare-ref-default,
8087
+.shithub-compare-ref-option.is-current .shithub-compare-ref-default {
8088
+  color: #ffffff;
8089
+  border-color: rgba(255, 255, 255, 0.65);
8090
+}
8091
+.shithub-compare-ref-empty {
8092
+  padding: 1rem;
8093
+  color: var(--fg-muted);
8094
+  text-align: center;
8095
+}
8096
+.shithub-compare-flash {
8097
+  display: flex;
8098
+  align-items: flex-start;
8099
+  gap: 0.75rem;
8100
+  padding: 0.75rem 1rem;
8101
+  margin: 0.5rem 0 1rem;
8102
+  border: 1px solid var(--border-default);
8103
+  border-radius: 6px;
8104
+  background: var(--canvas-subtle);
8105
+}
8106
+.shithub-compare-flash p {
8107
+  margin: 0.2rem 0 0;
8108
+  color: var(--fg-muted);
8109
+}
8110
+.shithub-compare-flash-warning {
8111
+  border-color: rgba(187, 128, 9, 0.45);
8112
+  background: rgba(187, 128, 9, 0.12);
8113
+}
8114
+.shithub-compare-flash-danger,
8115
+.shithub-compare-flash-conflict {
8116
+  border-color: rgba(248, 81, 73, 0.45);
8117
+  background: rgba(248, 81, 73, 0.10);
8118
+}
8119
+.shithub-compare-flash-clean,
8120
+.shithub-range-merge-clean {
8121
+  color: var(--success-fg);
8122
+}
8123
+.shithub-compare-blankslate {
8124
+  display: grid;
8125
+  justify-items: center;
8126
+  gap: 0.35rem;
8127
+  padding: 2.5rem 1rem;
8128
+  color: var(--fg-muted);
8129
+  text-align: center;
8130
+}
8131
+.shithub-compare-blankslate h2 {
8132
+  margin: 0;
8133
+  color: var(--fg-default);
8134
+  font-size: 1.25rem;
8135
+}
8136
+.shithub-compare-examples {
8137
+  width: min(28rem, 100%);
8138
+  margin-top: 0.75rem;
8139
+  overflow: hidden;
8140
+  text-align: left;
8141
+  border: 1px solid var(--border-default);
8142
+  border-radius: 6px;
8143
+}
8144
+.shithub-compare-examples-head,
8145
+.shithub-compare-examples a {
8146
+  display: flex;
8147
+  align-items: center;
8148
+  justify-content: space-between;
8149
+  gap: 0.75rem;
8150
+  padding: 0.55rem 0.75rem;
8151
+  border-bottom: 1px solid var(--border-default);
8152
+}
8153
+.shithub-compare-examples a:last-child {
8154
+  border-bottom: 0;
8155
+}
8156
+.shithub-compare-examples-head {
8157
+  color: var(--fg-muted);
8158
+  background: var(--canvas-subtle);
8159
+  font-weight: 600;
8160
+}
8161
+.shithub-compare-stats {
8162
+  display: grid;
8163
+  grid-template-columns: repeat(3, 1fr);
8164
+  margin: 1rem 0;
8165
+  border: 1px solid var(--border-default);
8166
+  border-radius: 6px;
8167
+  overflow: hidden;
8168
+}
8169
+.shithub-compare-stats span {
8170
+  display: flex;
8171
+  align-items: center;
8172
+  justify-content: center;
8173
+  gap: 0.4rem;
8174
+  padding: 0.75rem;
8175
+  color: var(--fg-muted);
8176
+  border-right: 1px solid var(--border-default);
8177
+}
8178
+.shithub-compare-stats span:last-child {
8179
+  border-right: 0;
8180
+}
8181
+.shithub-compare-commits {
8182
+  margin-top: 1.5rem;
8183
+}
8184
+.shithub-compare-commits h2 {
8185
+  margin: 0 0 0.5rem;
8186
+  font-size: 1rem;
8187
+}
8188
+.shithub-range-merge-state {
8189
+  display: inline-flex;
8190
+  align-items: center;
8191
+  gap: 0.25rem;
8192
+  font-weight: 600;
8193
+}
8194
+.shithub-range-merge-conflict,
8195
+.shithub-range-merge-missing {
8196
+  color: var(--danger-fg, #f85149);
8197
+}
8198
+.shithub-pull-open-flow {
8199
+  max-width: 72rem;
8200
+}
8201
+.shithub-pull-new-layout {
8202
+  display: grid;
8203
+  grid-template-columns: minmax(0, 1fr) 17rem;
8204
+  gap: 1.5rem;
8205
+  align-items: start;
8206
+  margin-top: 0.75rem;
8207
+}
8208
+.shithub-pull-new-form {
8209
+  min-width: 0;
8210
+}
8211
+.shithub-pull-new-title-row {
8212
+  display: grid;
8213
+  grid-template-columns: 2.5rem minmax(0, 1fr);
8214
+  gap: 0.75rem;
8215
+  align-items: start;
8216
+}
8217
+.shithub-pull-new-title,
8218
+.shithub-pull-new-description-label {
8219
+  display: grid;
8220
+  gap: 0.35rem;
8221
+  font-weight: 600;
8222
+}
8223
+.shithub-pull-new-title input {
8224
+  width: 100%;
8225
+  padding: 0.5rem 0.75rem;
8226
+  color: var(--fg-default);
8227
+  background: var(--canvas-default);
8228
+  border: 1px solid var(--border-default);
8229
+  border-radius: 6px;
8230
+  font: inherit;
8231
+}
8232
+.shithub-pull-new-description {
8233
+  margin: 0.75rem 0 0 3.25rem;
8234
+}
8235
+.shithub-pull-new-description .shithub-comment-editor-box {
8236
+  margin-top: 0.35rem;
8237
+}
8238
+.shithub-pull-new-actions {
8239
+  display: flex;
8240
+  justify-content: flex-end;
8241
+  align-items: stretch;
8242
+  gap: 0;
8243
+  margin-top: 0.75rem;
8244
+}
8245
+.shithub-pull-new-actions > .shithub-button-primary {
8246
+  border-top-right-radius: 0;
8247
+  border-bottom-right-radius: 0;
8248
+}
8249
+.shithub-pr-submit-menu {
8250
+  position: relative;
8251
+}
8252
+.shithub-pr-submit-menu > summary {
8253
+  height: 100%;
8254
+  border-top-left-radius: 0;
8255
+  border-bottom-left-radius: 0;
8256
+  list-style: none;
8257
+}
8258
+.shithub-pr-submit-menu > summary::-webkit-details-marker {
8259
+  display: none;
8260
+}
8261
+.shithub-pr-submit-menu-popover {
8262
+  position: absolute;
8263
+  z-index: 65;
8264
+  right: 0;
8265
+  top: calc(100% + 0.25rem);
8266
+  width: min(21rem, calc(100vw - 2rem));
8267
+  overflow: hidden;
8268
+  background: var(--canvas-overlay, var(--canvas-default));
8269
+  border: 1px solid var(--border-default);
8270
+  border-radius: 8px;
8271
+  box-shadow: var(--shadow-large, 0 16px 32px rgba(1, 4, 9, 0.35));
8272
+}
8273
+.shithub-pr-submit-menu-popover button {
8274
+  display: grid;
8275
+  gap: 0.25rem;
8276
+  width: 100%;
8277
+  padding: 0.75rem 1rem;
8278
+  color: var(--fg-default);
8279
+  text-align: left;
8280
+  background: transparent;
8281
+  border: 0;
8282
+  border-bottom: 1px solid var(--border-default);
8283
+  font: inherit;
8284
+  cursor: pointer;
8285
+}
8286
+.shithub-pr-submit-menu-popover button:last-child {
8287
+  border-bottom: 0;
8288
+}
8289
+.shithub-pr-submit-menu-popover button:hover,
8290
+.shithub-pr-submit-menu-popover button.is-active {
8291
+  color: #ffffff;
8292
+  background: var(--accent-emphasis);
8293
+}
8294
+.shithub-pr-submit-menu-popover span {
8295
+  color: inherit;
8296
+  opacity: 0.85;
8297
+  font-size: 0.85rem;
8298
+}
8299
+.shithub-pull-new-sidebar {
8300
+  display: grid;
8301
+  gap: 0;
8302
+  color: var(--fg-muted);
8303
+  font-size: 0.9rem;
8304
+}
8305
+.shithub-pull-new-sidebar section {
8306
+  padding: 0.75rem 0;
8307
+  border-bottom: 1px solid var(--border-default);
8308
+}
8309
+.shithub-pull-new-sidebar h2 {
8310
+  display: flex;
8311
+  align-items: center;
8312
+  justify-content: space-between;
8313
+  gap: 0.5rem;
8314
+  margin: 0 0 0.4rem;
8315
+  color: var(--fg-muted);
8316
+  font-size: 0.85rem;
8317
+}
8318
+.shithub-pull-new-sidebar p {
8319
+  margin: 0;
8320
+}
8321
+@media (max-width: 760px) {
8322
+  .shithub-range-create {
8323
+    margin-left: 0;
8324
+    width: 100%;
8325
+    justify-content: center;
8326
+  }
8327
+  .shithub-compare-stats {
8328
+    grid-template-columns: 1fr;
8329
+  }
8330
+  .shithub-compare-stats span {
8331
+    border-right: 0;
8332
+    border-bottom: 1px solid var(--border-default);
8333
+  }
8334
+  .shithub-compare-stats span:last-child {
8335
+    border-bottom: 0;
8336
+  }
8337
+  .shithub-pull-new-layout {
8338
+    grid-template-columns: 1fr;
8339
+  }
8340
+  .shithub-pull-new-description {
8341
+    margin-left: 0;
8342
+  }
8343
+  .shithub-pull-new-sidebar {
8344
+    order: 2;
8345
+  }
8346
+  .shithub-branch-push-banner,
8347
+  .shithub-branch-compare-bar {
8348
+    align-items: stretch;
8349
+    flex-direction: column;
8350
+  }
8351
+  .shithub-contribute-menu,
8352
+  .shithub-contribute-menu > summary,
8353
+  .shithub-branch-push-banner .shithub-button {
8354
+    width: 100%;
8355
+    justify-content: center;
8356
+  }
8357
+}
78838358
 .shithub-settings-branches form label { display: block; margin: 0.5rem 0; }
78848359
 .shithub-settings-branches form input[type=text],
78858360
 .shithub-settings-branches form select { font: inherit; padding: 0.4rem 0.6rem; border: 1px solid var(--border-default); border-radius: 6px; min-width: 280px; }
@@ -10483,6 +10958,21 @@ button.shithub-repo-action {
1048310958
   justify-content: center;
1048410959
   padding-top: 1rem;
1048510960
 }
10961
+.shithub-feed-pagination:empty {
10962
+  display: none;
10963
+}
10964
+.shithub-feed-more-button {
10965
+  min-width: 4.5rem;
10966
+}
10967
+.shithub-feed-more-loading {
10968
+  display: none;
10969
+}
10970
+.shithub-feed-more-button.htmx-request .shithub-feed-more-label {
10971
+  display: none;
10972
+}
10973
+.shithub-feed-more-button.htmx-request .shithub-feed-more-loading {
10974
+  display: inline;
10975
+}
1048610976
 .shithub-side-panel {
1048710977
   padding: 0 0 1.25rem;
1048810978
   margin-bottom: 1.25rem;
internal/web/static/js/compare.jsadded
60 lines changed — click to load
@@ -0,0 +1,60 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+(function () {
4
+  function setActivePanel(menu, name) {
5
+    menu.querySelectorAll("[data-ref-tab]").forEach(function (tab) {
6
+      var active = tab.getAttribute("data-ref-tab") === name;
7
+      tab.classList.toggle("is-active", active);
8
+      tab.setAttribute("aria-selected", active ? "true" : "false");
9
+    });
10
+    menu.querySelectorAll("[data-ref-panel]").forEach(function (panel) {
11
+      panel.hidden = panel.getAttribute("data-ref-panel") !== name;
12
+    });
13
+    var input = menu.querySelector("[data-ref-filter]");
14
+    if (input) {
15
+      input.value = "";
16
+      input.setAttribute("placeholder", name === "tags" ? "Find a tag" : "Find a branch");
17
+      input.setAttribute("aria-label", name === "tags" ? "Find a tag" : "Find a branch");
18
+      filterPanel(menu);
19
+      input.focus();
20
+    }
21
+  }
22
+
23
+  function filterPanel(menu) {
24
+    var input = menu.querySelector("[data-ref-filter]");
25
+    var query = input ? input.value.trim().toLowerCase() : "";
26
+    var panel = Array.prototype.find.call(menu.querySelectorAll("[data-ref-panel]"), function (candidate) {
27
+      return !candidate.hidden;
28
+    });
29
+    if (!panel) return;
30
+    var visible = 0;
31
+    panel.querySelectorAll("[data-ref-option]").forEach(function (option) {
32
+      var name = (option.getAttribute("data-ref-name") || option.textContent || "").toLowerCase();
33
+      var match = !query || name.indexOf(query) !== -1;
34
+      option.hidden = !match;
35
+      if (match) visible += 1;
36
+    });
37
+    var empty = panel.querySelector("[data-ref-empty]");
38
+    if (empty) empty.hidden = visible !== 0;
39
+  }
40
+
41
+  document.querySelectorAll("[data-ref-menu]").forEach(function (menu) {
42
+    menu.querySelectorAll("[data-ref-tab]").forEach(function (tab) {
43
+      tab.addEventListener("click", function () {
44
+        setActivePanel(menu, tab.getAttribute("data-ref-tab") || "branches");
45
+      });
46
+    });
47
+    var input = menu.querySelector("[data-ref-filter]");
48
+    if (input) {
49
+      input.addEventListener("input", function () { filterPanel(menu); });
50
+    }
51
+    menu.querySelectorAll("[data-ref-close]").forEach(function (close) {
52
+      close.addEventListener("click", function () { menu.open = false; });
53
+    });
54
+    menu.addEventListener("toggle", function () {
55
+      if (menu.open && input) {
56
+        setTimeout(function () { input.focus(); }, 0);
57
+      }
58
+    });
59
+  });
60
+})();
internal/web/templates/_compare_ref_menu.htmladded
44 lines changed — click to load
@@ -0,0 +1,44 @@
1
+{{ define "compare-ref-menu" -}}
2
+{{ $menu := .Menu -}}
3
+<details class="shithub-compare-ref-menu" data-ref-menu>
4
+  <summary class="shithub-button shithub-button-small shithub-compare-ref-summary">
5
+    <span class="shithub-compare-ref-label">{{ $menu.Label }}</span>
6
+    <span class="shithub-compare-ref-current">{{ $menu.Current }}</span>
7
+    {{ octicon "triangle-down" }}
8
+  </summary>
9
+  <div class="shithub-compare-ref-panel">
10
+    <div class="shithub-compare-ref-panel-head">
11
+      <strong>{{ $menu.Title }}</strong>
12
+      <button type="button" class="shithub-icon-button shithub-compare-ref-close" aria-label="Close" data-ref-close>{{ octicon "x" }}</button>
13
+    </div>
14
+    <div class="shithub-compare-ref-filter">
15
+      {{ octicon "search" }}
16
+      <input type="search" placeholder="Find a branch" aria-label="Find a branch" data-ref-filter>
17
+    </div>
18
+    <div class="shithub-compare-ref-tabs" role="tablist" aria-label="{{ $menu.Title }}">
19
+      <button type="button" class="is-active" role="tab" aria-selected="true" data-ref-tab="branches">Branches</button>
20
+      <button type="button" role="tab" aria-selected="false" data-ref-tab="tags">Tags</button>
21
+    </div>
22
+    <div class="shithub-compare-ref-list" data-ref-panel="branches">
23
+      {{ range $menu.Branches }}
24
+      <a href="{{ .Href }}" class="shithub-compare-ref-option{{ if .Current }} is-current{{ end }}" data-ref-option data-ref-name="{{ .Name }}">
25
+        <span class="shithub-compare-ref-check">{{ if .Current }}{{ octicon "check" }}{{ end }}</span>
26
+        <span class="shithub-compare-ref-option-name">{{ .Name }}</span>
27
+        {{ if .IsDefault }}<span class="shithub-compare-ref-default">default</span>{{ end }}
28
+      </a>
29
+      {{ end }}
30
+      <div class="shithub-compare-ref-empty" data-ref-empty hidden>No matching branches.</div>
31
+    </div>
32
+    <div class="shithub-compare-ref-list" data-ref-panel="tags" hidden>
33
+      {{ range $menu.Tags }}
34
+      <a href="{{ .Href }}" class="shithub-compare-ref-option{{ if .Current }} is-current{{ end }}" data-ref-option data-ref-name="{{ .Name }}">
35
+        <span class="shithub-compare-ref-check">{{ if .Current }}{{ octicon "check" }}{{ end }}</span>
36
+        <span class="shithub-compare-ref-option-name">{{ .Name }}</span>
37
+        {{ if .IsDefault }}<span class="shithub-compare-ref-default">default</span>{{ end }}
38
+      </a>
39
+      {{ end }}
40
+      <div class="shithub-compare-ref-empty" data-ref-empty hidden>No matching tags.</div>
41
+    </div>
42
+  </div>
43
+</details>
44
+{{- end }}
internal/web/templates/_explore_feed_pagination.htmladded
21 lines changed — click to load
@@ -0,0 +1,21 @@
1
+{{ define "explore-feed-pagination" -}}
2
+{{ if .FeedHasNext }}
3
+<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination">
4
+  <a
5
+    class="shithub-button shithub-feed-more-button"
6
+    href="{{ .FeedNextURL }}"
7
+    hx-get="{{ .FeedNextURL }}"
8
+    hx-target="#shithub-feed-list"
9
+    hx-swap="beforeend"
10
+    hx-select="#shithub-feed-fragment-rows > *"
11
+    hx-select-oob="#shithub-feed-pagination:outerHTML"
12
+    hx-indicator="this"
13
+  >
14
+    <span class="shithub-feed-more-label">More</span>
15
+    <span class="shithub-feed-more-loading">Loading...</span>
16
+  </a>
17
+</nav>
18
+{{ else }}
19
+<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination"></nav>
20
+{{ end }}
21
+{{- end }}
internal/web/templates/_layout.htmlmodified
7 lines changed — click to load
@@ -36,6 +36,7 @@
3636
   <link rel="stylesheet" href="/static/css/shithub.css">
3737
   <link rel="stylesheet" href="/static/css/chroma.css">
3838
   {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }}
39
+  {{ if flag . "UseCompareJS" }}<script src="/static/js/compare.js" defer></script>{{ end }}
3940
   {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }}
4041
 </head>
4142
 <body class="shithub-body">
internal/web/templates/explore/feed_page.htmladded
6 lines changed — click to load
@@ -0,0 +1,6 @@
1
+{{ define "page" -}}
2
+<div id="shithub-feed-fragment-rows">
3
+  {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
4
+</div>
5
+{{ template "explore-feed-pagination" . }}
6
+{{- end }}
internal/web/templates/explore/index.htmlmodified
20 lines changed — click to load
@@ -97,7 +97,7 @@
9797
         <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button>
9898
       </div>
9999
       {{ if .Feed }}
100
-      <ol class="shithub-feed-list">
100
+      <ol id="shithub-feed-list" class="shithub-feed-list" aria-live="polite">
101101
         {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
102102
       </ol>
103103
       {{ else }}
@@ -107,11 +107,7 @@
107107
         {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }}
108108
       </div>
109109
       {{ end }}
110
-      {{ if .FeedHasNext }}
111
-      <nav class="shithub-feed-pagination" aria-label="Public activity pagination">
112
-        <a class="shithub-button" href="{{ .FeedNextURL }}">More</a>
113
-      </nav>
114
-      {{ end }}
110
+      {{ template "explore-feed-pagination" . }}
115111
       {{ end }}
116112
     </main>
117113
 
internal/web/templates/repo/compare.htmlmodified
157 lines changed — click to load
@@ -1,49 +1,115 @@
11
 {{ define "page" -}}
2
-<section class="shithub-compare">
3
-  <header class="shithub-code-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .Repo.DefaultBranch }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
-      <span class="shithub-code-sep">/</span>
7
-      Compare <code>{{ .Base }}</code> ... <code>{{ .Head }}</code>
8
-    </h1>
9
-  </header>
2
+<section class="shithub-repo-page">
3
+  {{ template "repo-header" . }}
104
 
11
-  {{ if .NotFound }}
12
-  <div class="shithub-compare-empty">One or both refs were not found in this repository.</div>
13
-  {{ else }}
14
-  <p class="shithub-compare-summary">
15
-    {{ if eq .Base .Head }}
16
-    Base and head are the same — nothing to compare.
17
-    {{ else if le .Ahead 0 }}
18
-    There isn't anything to compare. <code>{{ .Head }}</code> is up to date with <code>{{ .Base }}</code>.
19
-    {{ else }}
20
-    <strong>{{ .Ahead }}</strong> commit{{ if ne .Ahead 1 }}s{{ end }} ahead, <strong>{{ .Behind }}</strong> behind <code>{{ .Base }}</code>.
21
-    <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/new?base={{ .Base }}&amp;head={{ .Head }}" class="shithub-button shithub-button-primary">Create pull request</a>
22
-    {{ end }}
23
-  </p>
5
+  <div class="shithub-compare-flow">
6
+    <header class="shithub-compare-subhead">
7
+      <h1>{{ if .HasSelection }}Comparing changes{{ else }}Compare changes{{ end }}</h1>
8
+      <p>
9
+        {{ if .HasSelection }}
10
+        Choose two branches to see what's changed or to start a new pull request.
11
+        {{ else }}
12
+        Compare changes across branches, commits, tags, and more below.
13
+        {{ end }}
14
+      </p>
15
+    </header>
2416
 
25
-  {{ if .Commits }}
26
-  <section class="shithub-compare-commits">
27
-    <h2>Commits in <code>{{ .Head }}</code></h2>
28
-    <ul class="shithub-commits-list">
29
-      {{ range .Commits }}
30
-      <li class="shithub-commits-row">
31
-        <div class="shithub-commits-meta">
32
-          <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a>
33
-          <code class="shithub-commits-sha">{{ .ShortOID }}</code>
34
-          <small>{{ .AuthorName }} · <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
35
-        </div>
36
-      </li>
17
+    <div class="shithub-range-editor" aria-label="Compare branches">
18
+      {{ template "compare-ref-menu" (dict "Menu" .BaseMenu) }}
19
+      <span class="shithub-range-separator" aria-hidden="true">...</span>
20
+      {{ template "compare-ref-menu" (dict "Menu" .HeadMenu) }}
21
+      {{ if .CanOpenPull }}
22
+      <a href="{{ .PullNewHref }}" class="shithub-button shithub-button-primary shithub-range-create">Create pull request</a>
23
+      {{ else }}
24
+      <button type="button" class="shithub-button shithub-button-primary shithub-range-create" disabled>Create pull request</button>
3725
       {{ end }}
38
-    </ul>
39
-  </section>
40
-  {{ end }}
26
+    </div>
27
+
28
+    {{ if .NotFound }}
29
+    <div class="shithub-compare-flash shithub-compare-flash-danger" role="alert">
30
+      {{ octicon "x-circle" }}
31
+      <div>
32
+        <strong>There was a problem comparing these refs.</strong>
33
+        <p>One or both refs were not found in this repository.</p>
34
+      </div>
35
+    </div>
36
+    {{ else if not .HasSelection }}
37
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
38
+      {{ octicon "git-pull-request" }}
39
+      <div>Choose different branches or tags above to discuss and review changes.</div>
40
+    </div>
41
+    <section class="shithub-compare-blankslate">
42
+      {{ octicon "git-pull-request" }}
43
+      <h2>Compare and review just about anything</h2>
44
+      <p>Branches, tags, commits, and time ranges in the same repository can be compared from here.</p>
45
+      {{ if .Examples }}
46
+      <div class="shithub-compare-examples">
47
+        <div class="shithub-compare-examples-head">Example comparisons</div>
48
+        {{ range .Examples }}
49
+        <a href="{{ .Href }}">
50
+          <span>{{ octicon "git-branch" }} {{ .Name }}</span>
51
+          <span>compare</span>
52
+        </a>
53
+        {{ end }}
54
+      </div>
55
+      {{ end }}
56
+    </section>
57
+    {{ else if .SameRef }}
58
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
59
+      {{ octicon "git-pull-request" }}
60
+      <div>Choose different branches or tags above to discuss and review changes.</div>
61
+    </div>
62
+    <section class="shithub-compare-blankslate">
63
+      {{ octicon "git-pull-request" }}
64
+      <h2>Compare and review just about anything</h2>
65
+      <p><code>{{ .Base }}</code> and <code>{{ .Head }}</code> are the same ref.</p>
66
+    </section>
67
+    {{ else if .NoCommits }}
68
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
69
+      {{ octicon "check-circle" }}
70
+      <div>
71
+        <strong>There isn't anything to compare.</strong>
72
+        <p><code>{{ .Head }}</code> is up to date with <code>{{ .Base }}</code>.</p>
73
+      </div>
74
+    </div>
75
+    {{ else }}
76
+    <div class="shithub-compare-flash shithub-compare-flash-{{ .MergeState.State }}">
77
+      {{ if eq .MergeState.State "clean" }}{{ octicon "check" }}{{ else if eq .MergeState.State "conflict" }}{{ octicon "x-circle" }}{{ else }}{{ octicon "git-pull-request" }}{{ end }}
78
+      <div>
79
+        <strong>{{ .MergeState.Label }}</strong>
80
+        <span>{{ .MergeState.Description }}</span>
81
+      </div>
82
+    </div>
4183
 
42
-  {{ if .DiffHTML }}
43
-  <section class="shithub-diff-body" aria-label="Diff">
44
-    {{ safeHTML .DiffHTML }}
45
-  </section>
46
-  {{ end }}
47
-  {{ end }}
84
+    <div class="shithub-compare-stats" aria-label="Comparison summary">
85
+      <span>{{ octicon "git-commit" }} {{ .Stats.CommitCount }} {{ pluralize .Stats.CommitCount "commit" "commits" }}</span>
86
+      <span>{{ octicon "diff" }} {{ .Stats.FileCount }} {{ pluralize .Stats.FileCount "file changed" "files changed" }}</span>
87
+      <span>{{ octicon "person" }} {{ .Stats.ContributorCount }} {{ pluralize .Stats.ContributorCount "contributor" "contributors" }}</span>
88
+    </div>
89
+
90
+    {{ if .Commits }}
91
+    <section class="shithub-compare-commits">
92
+      <h2>Commits on {{ .Head }}</h2>
93
+      <ul class="shithub-commits-list">
94
+        {{ range .Commits }}
95
+        <li class="shithub-commits-row">
96
+          <div class="shithub-commits-meta">
97
+            <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a>
98
+            <code class="shithub-commits-sha">{{ .ShortOID }}</code>
99
+            <small>{{ .AuthorName }} committed <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
100
+          </div>
101
+        </li>
102
+        {{ end }}
103
+      </ul>
104
+    </section>
105
+    {{ end }}
106
+
107
+    {{ if .DiffHTML }}
108
+    <section class="shithub-diff-body" aria-label="Diff">
109
+      {{ safeHTML .DiffHTML }}
110
+    </section>
111
+    {{ end }}
112
+    {{ end }}
113
+  </div>
48114
 </section>
49115
 {{- end }}
internal/web/templates/repo/pull_new.htmlmodified
248 lines changed — click to load
@@ -1,46 +1,207 @@
11
 {{ define "page" -}}
2
-<section class="shithub-issue-new">
3
-  <header class="shithub-issues-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
-      <span class="shithub-code-sep">/</span>
7
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls">Pull requests</a>
8
-      <span class="shithub-code-sep">/</span>
9
-      New
10
-    </h1>
11
-  </header>
12
-
13
-  {{ if .Error }}<div class="shithub-error" role="alert">{{ .Error }}</div>{{ end }}
14
-
15
-  <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-issue-form">
16
-    <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
17
-    <div class="shithub-form-row shithub-pull-refs">
18
-      <label>
19
-        <span>Base</span>
20
-        <input type="text" name="base" value="{{ .Base }}" required>
21
-      </label>
22
-      <span class="shithub-pull-arrow">←</span>
23
-      <label>
24
-        <span>Head</span>
25
-        <input type="text" name="head" value="{{ .Head }}" required>
26
-      </label>
2
+<section class="shithub-repo-page">
3
+  {{ template "repo-header" . }}
4
+
5
+  <div class="shithub-compare-flow shithub-pull-open-flow">
6
+    <header class="shithub-compare-subhead">
7
+      <h1>Open a pull request</h1>
8
+      <p>Create a new pull request by comparing changes across two branches.</p>
9
+    </header>
10
+
11
+    <div class="shithub-range-editor" aria-label="Choose pull request branches">
12
+      {{ template "compare-ref-menu" (dict "Menu" .BaseMenu) }}
13
+      <span class="shithub-range-separator" aria-hidden="true">...</span>
14
+      {{ template "compare-ref-menu" (dict "Menu" .HeadMenu) }}
15
+      {{ if .MergeState.Label }}
16
+      <span class="shithub-range-merge-state shithub-range-merge-{{ .MergeState.State }}">
17
+        {{ if eq .MergeState.State "clean" }}{{ octicon "check" }}{{ else if eq .MergeState.State "conflict" }}{{ octicon "x-circle" }}{{ else }}{{ octicon "git-pull-request" }}{{ end }}
18
+        {{ .MergeState.Label }}
19
+      </span>
20
+      {{ end }}
21
+    </div>
22
+
23
+    {{ if .Error }}<div class="shithub-error" role="alert">{{ .Error }}</div>{{ end }}
24
+    {{ if .NotFound }}
25
+    <div class="shithub-compare-flash shithub-compare-flash-danger" role="alert">
26
+      {{ octicon "x-circle" }}
27
+      <div>
28
+        <strong>There was a problem comparing these refs.</strong>
29
+        <p>One or both refs were not found in this repository.</p>
30
+      </div>
2731
     </div>
28
-    <label class="shithub-form-row">
29
-      <span>Title</span>
30
-      <input type="text" name="title" maxlength="256" required value="{{ .FormTitle }}" autofocus>
31
-    </label>
32
-    <label class="shithub-form-row">
33
-      <span>Body (Markdown)</span>
34
-      <textarea name="body" rows="14" maxlength="65535">{{ .FormBody }}</textarea>
35
-    </label>
36
-    <label class="shithub-form-row">
37
-      <input type="checkbox" name="draft" value="on">
38
-      Open as draft
39
-    </label>
40
-    <div class="shithub-form-actions">
41
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-button">Cancel</a>
42
-      <button type="submit" class="shithub-button shithub-button-primary">Create pull request</button>
32
+    {{ else if or .SameRef .NoCommits }}
33
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
34
+      {{ octicon "git-pull-request" }}
35
+      <div>
36
+        <strong>Choose different branches to open a pull request.</strong>
37
+        <p><code>{{ .Head }}</code> has no commits ahead of <code>{{ .Base }}</code>.</p>
38
+      </div>
4339
     </div>
44
-  </form>
40
+    {{ end }}
41
+
42
+    <div class="shithub-pull-new-layout">
43
+      <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-pull-new-form">
44
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
45
+        <input type="hidden" name="base" value="{{ .Base }}">
46
+        <input type="hidden" name="head" value="{{ .Head }}">
47
+
48
+        <div class="shithub-pull-new-title-row">
49
+          <a class="shithub-comment-composer-avatar" href="/{{ .Viewer.Username }}" aria-label="@{{ .Viewer.Username }}">
50
+            <img src="{{ .ViewerAvatarURL }}" alt="" width="40" height="40">
51
+          </a>
52
+          <label class="shithub-pull-new-title">
53
+            <span>Add a title <strong>*</strong></span>
54
+            <input type="text" name="title" maxlength="256" required value="{{ .FormTitle }}" autofocus>
55
+          </label>
56
+        </div>
57
+
58
+        <div class="shithub-pull-new-description" data-comment-editor data-preview-url="/{{ .Owner }}/{{ .Repo.Name }}/markdown-preview" data-preview-ref="{{ .Base }}">
59
+          <script type="application/json" data-comment-editor-config>{{ jsField . "CommentEditorConfig" }}</script>
60
+          <label class="shithub-pull-new-description-label" for="pull-new-body">Add a description</label>
61
+          <div class="shithub-comment-editor-box">
62
+            <div class="shithub-comment-editor-head">
63
+              <div class="shithub-comment-editor-tabs" role="tablist" aria-label="Pull request description tabs">
64
+                <button type="button" class="is-active" role="tab" aria-selected="true" data-comment-tab="write">Write</button>
65
+                <button type="button" role="tab" aria-selected="false" data-comment-tab="preview">Preview</button>
66
+              </div>
67
+              <div class="shithub-comment-toolbar" aria-label="Formatting tools">
68
+                <button type="button" class="shithub-comment-tool" data-comment-action="mention" title="Mention a user">{{ octicon "people" }}</button>
69
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
70
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="heading" title="Add heading">H</button>
71
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="bold" title="Add bold text">B</button>
72
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text is-italic" data-comment-action="italic" title="Add italic text">I</button>
73
+                <button type="button" class="shithub-comment-tool" data-comment-action="quote" title="Quote text">{{ octicon "comment" }}</button>
74
+                <button type="button" class="shithub-comment-tool" data-comment-action="code" title="Add code">{{ octicon "code" }}</button>
75
+                <button type="button" class="shithub-comment-tool" data-comment-action="link" title="Add link">{{ octicon "link" }}</button>
76
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
77
+                <button type="button" class="shithub-comment-tool" data-comment-action="list" title="Add unordered list">{{ octicon "list-unordered" }}</button>
78
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="ordered-list" title="Add ordered list">1.</button>
79
+                <button type="button" class="shithub-comment-tool" data-comment-action="task-list" title="Add task list">{{ octicon "checklist" }}</button>
80
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
81
+                <label class="shithub-comment-tool" title="Attach files">
82
+                  {{ octicon "upload" }}
83
+                  <input type="file" multiple data-comment-file-input>
84
+                </label>
85
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="reference" title="Reference an issue or pull request">#</button>
86
+                <button type="button" class="shithub-comment-tool" data-comment-saved-replies-open title="Saved replies">{{ octicon "comment-discussion" }}</button>
87
+                <button type="button" class="shithub-comment-tool" data-comment-action="fullscreen" title="Fullscreen editor">{{ octicon "screen-full" }}</button>
88
+              </div>
89
+            </div>
90
+            <div class="shithub-comment-editor-write" data-comment-write-pane>
91
+              <textarea id="pull-new-body" name="body" rows="10" maxlength="65535" placeholder="Add your description here..." data-comment-textarea>{{ .FormBody }}</textarea>
92
+              <div class="shithub-comment-suggestions" data-comment-suggestions hidden></div>
93
+            </div>
94
+            <div class="shithub-comment-editor-preview markdown-body" data-comment-preview-pane hidden>
95
+              <p class="shithub-editor-preview-empty">Nothing to preview.</p>
96
+            </div>
97
+            <div class="shithub-comment-editor-footer">
98
+              <span>{{ octicon "code-square" }} Markdown is supported</span>
99
+              <span data-comment-attachment-copy>{{ octicon "file" }} Paste, drop, or click to add files</span>
100
+              <span class="shithub-comment-file-list" data-comment-file-list hidden></span>
101
+            </div>
102
+          </div>
103
+          <dialog class="shithub-comment-saved-dialog" data-comment-saved-dialog>
104
+            <div class="shithub-comment-saved-head">
105
+              <strong>Select a reply</strong>
106
+              <button type="button" class="shithub-icon-button" aria-label="Close" data-comment-saved-close>{{ octicon "x" }}</button>
107
+            </div>
108
+            <input type="search" placeholder="Search saved replies" data-comment-saved-filter>
109
+            <button type="button" class="shithub-comment-saved-item" data-comment-saved-insert="Duplicate of #">
110
+              <strong>Duplicate pull request</strong>
111
+              <span>Duplicate of #</span>
112
+              <kbd>ctrl 1</kbd>
113
+            </button>
114
+            <button type="button" class="shithub-comment-saved-create">{{ octicon "plus" }} Create a new saved reply</button>
115
+          </dialog>
116
+        </div>
117
+
118
+        <p class="shithub-comment-policy-note">
119
+          {{ octicon "alert" }} Remember, contributions to this repository should follow its
120
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CONTRIBUTING.md">contributing guidelines</a>,
121
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/SECURITY.md">security policy</a>, and
122
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CODE_OF_CONDUCT.md">code of conduct</a>.
123
+        </p>
124
+
125
+        <div class="shithub-pull-new-actions">
126
+          {{ if .CanCreatePull }}
127
+          <button type="submit" class="shithub-button shithub-button-primary">Create pull request</button>
128
+          <details class="shithub-pr-submit-menu">
129
+            <summary class="shithub-button shithub-button-primary shithub-button-icon" aria-label="Create options">{{ octicon "triangle-down" }}</summary>
130
+            <div class="shithub-pr-submit-menu-popover">
131
+              <button type="submit" class="is-active">
132
+                <strong>{{ octicon "check" }} Create pull request</strong>
133
+                <span>Open a pull request that is ready for review</span>
134
+              </button>
135
+              <button type="submit" name="draft" value="on">
136
+                <strong>Create draft pull request</strong>
137
+                <span>Cannot be merged until marked ready for review</span>
138
+              </button>
139
+            </div>
140
+          </details>
141
+          {{ else }}
142
+          <button type="submit" class="shithub-button shithub-button-primary" disabled>Create pull request</button>
143
+          {{ end }}
144
+        </div>
145
+      </form>
146
+
147
+      <aside class="shithub-pull-new-sidebar" aria-label="Pull request metadata">
148
+        <section>
149
+          <h2>Reviewers <button type="button" class="shithub-icon-button" aria-label="Edit reviewers">{{ octicon "gear" }}</button></h2>
150
+          <p>No reviewers</p>
151
+        </section>
152
+        <section>
153
+          <h2>Assignees <button type="button" class="shithub-icon-button" aria-label="Edit assignees">{{ octicon "gear" }}</button></h2>
154
+          <p>No one - <a href="/{{ .Viewer.Username }}">assign yourself</a></p>
155
+        </section>
156
+        <section>
157
+          <h2>Labels <button type="button" class="shithub-icon-button" aria-label="Edit labels">{{ octicon "gear" }}</button></h2>
158
+          <p>None yet</p>
159
+        </section>
160
+        <section>
161
+          <h2>Projects <button type="button" class="shithub-icon-button" aria-label="Edit projects">{{ octicon "gear" }}</button></h2>
162
+          <p>None yet</p>
163
+        </section>
164
+        <section>
165
+          <h2>Milestone <button type="button" class="shithub-icon-button" aria-label="Edit milestone">{{ octicon "gear" }}</button></h2>
166
+          <p>No milestone</p>
167
+        </section>
168
+        <section>
169
+          <h2>Development</h2>
170
+          <p>Use closing keywords in the description to automatically close issues</p>
171
+        </section>
172
+      </aside>
173
+    </div>
174
+
175
+    {{ if and (not .NotFound) (not .SameRef) }}
176
+    <div class="shithub-compare-stats" aria-label="Comparison summary">
177
+      <span>{{ octicon "git-commit" }} {{ .Stats.CommitCount }} {{ pluralize .Stats.CommitCount "commit" "commits" }}</span>
178
+      <span>{{ octicon "diff" }} {{ .Stats.FileCount }} {{ pluralize .Stats.FileCount "file changed" "files changed" }}</span>
179
+      <span>{{ octicon "person" }} {{ .Stats.ContributorCount }} {{ pluralize .Stats.ContributorCount "contributor" "contributors" }}</span>
180
+    </div>
181
+
182
+    {{ if .Commits }}
183
+    <section class="shithub-compare-commits">
184
+      <h2>Commits on {{ .Head }}</h2>
185
+      <ul class="shithub-commits-list">
186
+        {{ range .Commits }}
187
+        <li class="shithub-commits-row">
188
+          <div class="shithub-commits-meta">
189
+            <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a>
190
+            <code class="shithub-commits-sha">{{ .ShortOID }}</code>
191
+            <small>{{ .AuthorName }} committed <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
192
+          </div>
193
+        </li>
194
+        {{ end }}
195
+      </ul>
196
+    </section>
197
+    {{ end }}
198
+
199
+    {{ if .DiffHTML }}
200
+    <section class="shithub-diff-body" aria-label="Diff">
201
+      {{ safeHTML .DiffHTML }}
202
+    </section>
203
+    {{ end }}
204
+    {{ end }}
205
+  </div>
45206
 </section>
46207
 {{- end }}
internal/web/templates/repo/pulls_list.htmlmodified
8 lines changed — click to load
@@ -5,7 +5,7 @@
55
   <header class="shithub-issues-head">
66
     <h1>Pull requests</h1>
77
     <div class="shithub-issues-actions">
8
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare/{{ .Repo.DefaultBranch }}...{{ .Repo.DefaultBranch }}" class="shithub-button shithub-button-primary">New pull request</a>
8
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare" class="shithub-button shithub-button-primary">New pull request</a>
99
     </div>
1010
   </header>
1111
 
internal/web/templates/repo/tree.htmlmodified
44 lines changed — click to load
@@ -67,6 +67,44 @@
6767
           </div>
6868
         </header>
6969
 
70
+        {{ if .BranchCompare.Show }}
71
+        {{ if and .BranchCompare.HasRecentPush .HeadFound }}
72
+        <div class="shithub-branch-push-banner">
73
+          <div>
74
+            {{ octicon "git-pull-request" }}
75
+            <strong>{{ .BranchCompare.Head }}</strong> had recent pushes <time datetime="{{ .Head.AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .Head.AuthorWhen }}</time>
76
+          </div>
77
+          <a href="{{ .BranchCompare.PullNewHref }}" class="shithub-button shithub-button-primary">Compare &amp; pull request</a>
78
+        </div>
79
+        {{ end }}
80
+        <div class="shithub-branch-compare-bar">
81
+          <div>
82
+            This branch is
83
+            <a href="{{ .BranchCompare.CompareHref }}">{{ .BranchCompare.Ahead }} {{ pluralize .BranchCompare.Ahead "commit" "commits" }} ahead</a>
84
+            of and
85
+            <a href="{{ .BranchCompare.CompareHref }}">{{ .BranchCompare.Behind }} {{ pluralize .BranchCompare.Behind "commit" "commits" }} behind</a>
86
+            <code>{{ .BranchCompare.Base }}</code>.
87
+          </div>
88
+          <details class="shithub-contribute-menu">
89
+            <summary class="shithub-button">{{ octicon "git-pull-request" }} Contribute {{ octicon "triangle-down" }}</summary>
90
+            <div class="shithub-contribute-menu-panel">
91
+              <div>
92
+                <strong>This branch is {{ .BranchCompare.Ahead }} {{ pluralize .BranchCompare.Ahead "commit" "commits" }} ahead of <code>{{ .BranchCompare.Base }}</code>.</strong>
93
+                <p>Open a pull request to contribute your changes upstream.</p>
94
+              </div>
95
+              <div class="shithub-contribute-actions">
96
+                <a href="{{ .BranchCompare.CompareHref }}" class="shithub-button">Compare</a>
97
+                {{ if gt .BranchCompare.Ahead 0 }}
98
+                <a href="{{ .BranchCompare.PullNewHref }}" class="shithub-button shithub-button-primary">Open pull request</a>
99
+                {{ else }}
100
+                <button type="button" class="shithub-button shithub-button-primary" disabled>Open pull request</button>
101
+                {{ end }}
102
+              </div>
103
+            </div>
104
+          </details>
105
+        </div>
106
+        {{ end }}
107
+
70108
         {{ if .Path }}
71109
         <nav class="shithub-code-crumbs" aria-label="Breadcrumb">
72110
           {{ range $i, $c := .Crumbs }}
internal/webhook/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
internal/worker/sqlc/models.gomodified
268 lines changed — click to load
@@ -12,6 +12,184 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 )
1414
 
15
+type BillingInvoiceStatus string
16
+
17
+const (
18
+	BillingInvoiceStatusDraft         BillingInvoiceStatus = "draft"
19
+	BillingInvoiceStatusOpen          BillingInvoiceStatus = "open"
20
+	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
21
+	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
22
+	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
23
+)
24
+
25
+func (e *BillingInvoiceStatus) Scan(src interface{}) error {
26
+	switch s := src.(type) {
27
+	case []byte:
28
+		*e = BillingInvoiceStatus(s)
29
+	case string:
30
+		*e = BillingInvoiceStatus(s)
31
+	default:
32
+		return fmt.Errorf("unsupported scan type for BillingInvoiceStatus: %T", src)
33
+	}
34
+	return nil
35
+}
36
+
37
+type NullBillingInvoiceStatus struct {
38
+	BillingInvoiceStatus BillingInvoiceStatus
39
+	Valid                bool // Valid is true if BillingInvoiceStatus is not NULL
40
+}
41
+
42
+// Scan implements the Scanner interface.
43
+func (ns *NullBillingInvoiceStatus) Scan(value interface{}) error {
44
+	if value == nil {
45
+		ns.BillingInvoiceStatus, ns.Valid = "", false
46
+		return nil
47
+	}
48
+	ns.Valid = true
49
+	return ns.BillingInvoiceStatus.Scan(value)
50
+}
51
+
52
+// Value implements the driver Valuer interface.
53
+func (ns NullBillingInvoiceStatus) Value() (driver.Value, error) {
54
+	if !ns.Valid {
55
+		return nil, nil
56
+	}
57
+	return string(ns.BillingInvoiceStatus), nil
58
+}
59
+
60
+type BillingLockReason string
61
+
62
+const (
63
+	BillingLockReasonPastDue  BillingLockReason = "past_due"
64
+	BillingLockReasonCanceled BillingLockReason = "canceled"
65
+	BillingLockReasonUnpaid   BillingLockReason = "unpaid"
66
+	BillingLockReasonManual   BillingLockReason = "manual"
67
+)
68
+
69
+func (e *BillingLockReason) Scan(src interface{}) error {
70
+	switch s := src.(type) {
71
+	case []byte:
72
+		*e = BillingLockReason(s)
73
+	case string:
74
+		*e = BillingLockReason(s)
75
+	default:
76
+		return fmt.Errorf("unsupported scan type for BillingLockReason: %T", src)
77
+	}
78
+	return nil
79
+}
80
+
81
+type NullBillingLockReason struct {
82
+	BillingLockReason BillingLockReason
83
+	Valid             bool // Valid is true if BillingLockReason is not NULL
84
+}
85
+
86
+// Scan implements the Scanner interface.
87
+func (ns *NullBillingLockReason) Scan(value interface{}) error {
88
+	if value == nil {
89
+		ns.BillingLockReason, ns.Valid = "", false
90
+		return nil
91
+	}
92
+	ns.Valid = true
93
+	return ns.BillingLockReason.Scan(value)
94
+}
95
+
96
+// Value implements the driver Valuer interface.
97
+func (ns NullBillingLockReason) Value() (driver.Value, error) {
98
+	if !ns.Valid {
99
+		return nil, nil
100
+	}
101
+	return string(ns.BillingLockReason), nil
102
+}
103
+
104
+type BillingProvider string
105
+
106
+const (
107
+	BillingProviderStripe BillingProvider = "stripe"
108
+)
109
+
110
+func (e *BillingProvider) Scan(src interface{}) error {
111
+	switch s := src.(type) {
112
+	case []byte:
113
+		*e = BillingProvider(s)
114
+	case string:
115
+		*e = BillingProvider(s)
116
+	default:
117
+		return fmt.Errorf("unsupported scan type for BillingProvider: %T", src)
118
+	}
119
+	return nil
120
+}
121
+
122
+type NullBillingProvider struct {
123
+	BillingProvider BillingProvider
124
+	Valid           bool // Valid is true if BillingProvider is not NULL
125
+}
126
+
127
+// Scan implements the Scanner interface.
128
+func (ns *NullBillingProvider) Scan(value interface{}) error {
129
+	if value == nil {
130
+		ns.BillingProvider, ns.Valid = "", false
131
+		return nil
132
+	}
133
+	ns.Valid = true
134
+	return ns.BillingProvider.Scan(value)
135
+}
136
+
137
+// Value implements the driver Valuer interface.
138
+func (ns NullBillingProvider) Value() (driver.Value, error) {
139
+	if !ns.Valid {
140
+		return nil, nil
141
+	}
142
+	return string(ns.BillingProvider), nil
143
+}
144
+
145
+type BillingSubscriptionStatus string
146
+
147
+const (
148
+	BillingSubscriptionStatusNone       BillingSubscriptionStatus = "none"
149
+	BillingSubscriptionStatusIncomplete BillingSubscriptionStatus = "incomplete"
150
+	BillingSubscriptionStatusTrialing   BillingSubscriptionStatus = "trialing"
151
+	BillingSubscriptionStatusActive     BillingSubscriptionStatus = "active"
152
+	BillingSubscriptionStatusPastDue    BillingSubscriptionStatus = "past_due"
153
+	BillingSubscriptionStatusCanceled   BillingSubscriptionStatus = "canceled"
154
+	BillingSubscriptionStatusUnpaid     BillingSubscriptionStatus = "unpaid"
155
+	BillingSubscriptionStatusPaused     BillingSubscriptionStatus = "paused"
156
+)
157
+
158
+func (e *BillingSubscriptionStatus) Scan(src interface{}) error {
159
+	switch s := src.(type) {
160
+	case []byte:
161
+		*e = BillingSubscriptionStatus(s)
162
+	case string:
163
+		*e = BillingSubscriptionStatus(s)
164
+	default:
165
+		return fmt.Errorf("unsupported scan type for BillingSubscriptionStatus: %T", src)
166
+	}
167
+	return nil
168
+}
169
+
170
+type NullBillingSubscriptionStatus struct {
171
+	BillingSubscriptionStatus BillingSubscriptionStatus
172
+	Valid                     bool // Valid is true if BillingSubscriptionStatus is not NULL
173
+}
174
+
175
+// Scan implements the Scanner interface.
176
+func (ns *NullBillingSubscriptionStatus) Scan(value interface{}) error {
177
+	if value == nil {
178
+		ns.BillingSubscriptionStatus, ns.Valid = "", false
179
+		return nil
180
+	}
181
+	ns.Valid = true
182
+	return ns.BillingSubscriptionStatus.Scan(value)
183
+}
184
+
185
+// Value implements the driver Valuer interface.
186
+func (ns NullBillingSubscriptionStatus) Value() (driver.Value, error) {
187
+	if !ns.Valid {
188
+		return nil, nil
189
+	}
190
+	return string(ns.BillingSubscriptionStatus), nil
191
+}
192
+
15193
 type CheckConclusion string
16194
 
17195
 const (
@@ -1601,6 +1779,54 @@ type AuthThrottle struct {
16011779
 	WindowStartedAt pgtype.Timestamptz
16021780
 }
16031781
 
1782
+type BillingInvoice struct {
1783
+	ID                   int64
1784
+	OrgID                int64
1785
+	Provider             BillingProvider
1786
+	StripeInvoiceID      string
1787
+	StripeCustomerID     string
1788
+	StripeSubscriptionID pgtype.Text
1789
+	Status               BillingInvoiceStatus
1790
+	Number               string
1791
+	Currency             string
1792
+	AmountDueCents       int64
1793
+	AmountPaidCents      int64
1794
+	AmountRemainingCents int64
1795
+	HostedInvoiceUrl     string
1796
+	InvoicePdfUrl        string
1797
+	PeriodStart          pgtype.Timestamptz
1798
+	PeriodEnd            pgtype.Timestamptz
1799
+	DueAt                pgtype.Timestamptz
1800
+	PaidAt               pgtype.Timestamptz
1801
+	VoidedAt             pgtype.Timestamptz
1802
+	CreatedAt            pgtype.Timestamptz
1803
+	UpdatedAt            pgtype.Timestamptz
1804
+}
1805
+
1806
+type BillingSeatSnapshot struct {
1807
+	ID                   int64
1808
+	OrgID                int64
1809
+	Provider             BillingProvider
1810
+	StripeSubscriptionID pgtype.Text
1811
+	ActiveMembers        int32
1812
+	BillableSeats        int32
1813
+	Source               string
1814
+	CapturedAt           pgtype.Timestamptz
1815
+}
1816
+
1817
+type BillingWebhookEvent struct {
1818
+	ID                 int64
1819
+	Provider           BillingProvider
1820
+	ProviderEventID    string
1821
+	EventType          string
1822
+	ApiVersion         string
1823
+	Payload            []byte
1824
+	ReceivedAt         pgtype.Timestamptz
1825
+	ProcessedAt        pgtype.Timestamptz
1826
+	ProcessError       string
1827
+	ProcessingAttempts int32
1828
+}
1829
+
16041830
 type BranchProtectionRule struct {
16051831
 	ID                             int64
16061832
 	RepoID                         int64
@@ -1870,6 +2096,30 @@ type Org struct {
18702096
 	UpdatedAt             pgtype.Timestamptz
18712097
 }
18722098
 
2099
+type OrgBillingState struct {
2100
+	OrgID                    int64
2101
+	Provider                 BillingProvider
2102
+	StripeCustomerID         pgtype.Text
2103
+	StripeSubscriptionID     pgtype.Text
2104
+	StripeSubscriptionItemID pgtype.Text
2105
+	Plan                     OrgPlan
2106
+	SubscriptionStatus       BillingSubscriptionStatus
2107
+	BillableSeats            int32
2108
+	SeatSnapshotAt           pgtype.Timestamptz
2109
+	CurrentPeriodStart       pgtype.Timestamptz
2110
+	CurrentPeriodEnd         pgtype.Timestamptz
2111
+	CancelAtPeriodEnd        bool
2112
+	TrialEnd                 pgtype.Timestamptz
2113
+	PastDueAt                pgtype.Timestamptz
2114
+	CanceledAt               pgtype.Timestamptz
2115
+	LockedAt                 pgtype.Timestamptz
2116
+	LockReason               NullBillingLockReason
2117
+	GraceUntil               pgtype.Timestamptz
2118
+	LastWebhookEventID       string
2119
+	CreatedAt                pgtype.Timestamptz
2120
+	UpdatedAt                pgtype.Timestamptz
2121
+}
2122
+
18732123
 type OrgGithubImport struct {
18742124
 	ID                int64
18752125
 	OrgID             int64
scripts/lint-org-plan-boundary.shadded
41 lines changed — click to load
@@ -0,0 +1,41 @@
1
+#!/usr/bin/env bash
2
+# SPDX-License-Identifier: AGPL-3.0-or-later
3
+#
4
+# Fail when paid-organization feature decisions branch directly on
5
+# orgs.plan outside the billing/entitlement boundary. Plan storage is
6
+# allowed in schema/sqlc plumbing, but handlers and domain packages
7
+# should ask the entitlement layer whether a feature is available.
8
+#
9
+# Allowed locations:
10
+#   internal/billing/...          — owns subscription state
11
+#   internal/entitlements/...     — owns feature availability
12
+#   internal/*/sqlc/...           — generated data models
13
+#   internal/migrationsfs/...     — schema definitions
14
+#   *_test.go everywhere          — tests may seed or assert plan values
15
+#
16
+# Run from `make ci`.
17
+
18
+set -euo pipefail
19
+
20
+cd "$(git rev-parse --show-toplevel)"
21
+
22
+PATTERN='OrgPlan(Free|Team|Enterprise)|\.Plan[[:space:]]*(==|!=)|orgs\.plan|plan[[:space:]]+org_plan'
23
+
24
+matches=$(git grep -nE "$PATTERN" -- \
25
+  'cmd' 'internal' \
26
+  ':!internal/billing/*' \
27
+  ':!internal/entitlements/*' \
28
+  ':!internal/*/sqlc/*' \
29
+  ':!internal/migrationsfs/*' \
30
+  ':!**/*_test.go' \
31
+  2>/dev/null || true)
32
+
33
+if [[ -n "$matches" ]]; then
34
+  echo "lint-org-plan-boundary: direct org plan feature gate outside billing/entitlements:" >&2
35
+  echo "$matches" | sed 's/^/  /' >&2
36
+  echo "" >&2
37
+  echo "Fix: add or use an entitlement feature check instead of branching on orgs.plan directly." >&2
38
+  exit 1
39
+fi
40
+
41
+echo "lint-org-plan-boundary: ok"
sqlc.yamlmodified
19 lines changed — click to load
@@ -241,3 +241,19 @@ sql:
241241
         emit_exact_table_names: false
242242
         emit_empty_slices: true
243243
         emit_methods_with_db_argument: true
244
+
245
+  - engine: postgresql
246
+    schema: internal/migrationsfs/migrations
247
+    queries: internal/billing/queries
248
+    gen:
249
+      go:
250
+        package: billingdb
251
+        out: internal/billing/sqlc
252
+        sql_package: pgx/v5
253
+        emit_json_tags: false
254
+        emit_pointers_for_null_types: false
255
+        emit_prepared_queries: false
256
+        emit_interface: true
257
+        emit_exact_table_names: false
258
+        emit_empty_slices: true
259
+        emit_methods_with_db_argument: true
Diff truncated: 104 files; expand each to load its hunks.