shithub Public
Comparing changes
Choose two branches to see what's changed or to start a new pull request.
Able to merge.
These branches can be automatically merged.
34 commits
104 files changed
3 contributors
Commits on s50/cross-cutting
.env.examplemodified9 lines changed — click to load
@@ -47,3 +47,9 @@ SHITHUB_AUTH__SMTP__ADDR=127.0.0.1:1025 | ||
| 47 | 47 | # AEAD key for at-rest TOTP secrets (S06). Generate once and persist — |
| 48 | 48 | # rotating without re-encrypting every row breaks every existing 2FA login. |
| 49 | 49 | # 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.mdmodified29 lines changed — click to load
@@ -10,7 +10,28 @@ between minor releases. | ||
| 10 | 10 | |
| 11 | 11 | ## [Unreleased] |
| 12 | 12 | |
| 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. | |
| 14 | 35 | |
| 15 | 36 | ## [0.1.0] — TBD (operator fills in cutover date) |
| 16 | 37 | |
Makefilemodified25 lines changed — click to load
@@ -2,7 +2,7 @@ | ||
| 2 | 2 | # Targets mirror what CI runs. The Makefile is the source of truth. |
| 3 | 3 | |
| 4 | 4 | .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 | |
| 6 | 6 | |
| 7 | 7 | # Build metadata embedded into the binary via -ldflags. |
| 8 | 8 | 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. | ||
| 72 | 72 | echo "warn: .refs/primer-css/dist not found; run 'git clone https://github.com/primer/css .refs/primer-css' first"; \ |
| 73 | 73 | fi |
| 74 | 74 | |
| 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). | |
| 76 | 76 | @echo "ci: ok" |
| 77 | 77 | |
| 78 | 78 | 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 | ||
| 81 | 81 | lint-markdown: ## Enforce markdown-package boundary (no goldmark/bluemonday outside internal/markdown). |
| 82 | 82 | @scripts/lint-markdown-boundary.sh |
| 83 | 83 | |
| 84 | +lint-org-plan: ## Enforce paid org entitlement boundary (no direct orgs.plan feature gates). | |
| 85 | + @scripts/lint-org-plan-boundary.sh | |
| 86 | + | |
| 84 | 87 | lint-secret-logs: ## Fail when source emits log lines containing token-prefix patterns. |
| 85 | 88 | @scripts/lint-secret-logs.sh |
| 86 | 89 | |
bench/fixtures/README.mdmodified11 lines changed — click to load
@@ -38,3 +38,11 @@ fixtures used by `make bench-small`. That's enough to catch | ||
| 38 | 38 | regressions in the harness itself plus the small-scale handler |
| 39 | 39 | latency floor; the big-fixture targets in S36's "Definition of |
| 40 | 40 | 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.ymladded10 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.gomodified136 lines changed — click to load
@@ -3,13 +3,20 @@ | ||
| 3 | 3 | package main |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "context" | |
| 6 | 7 | "encoding/json" |
| 8 | + "errors" | |
| 7 | 9 | "fmt" |
| 8 | 10 | "os" |
| 11 | + "time" | |
| 9 | 12 | |
| 10 | 13 | "github.com/spf13/cobra" |
| 11 | 14 | |
| 15 | + actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle" | |
| 16 | + actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" | |
| 12 | 17 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/infra/config" | |
| 19 | + "github.com/tenseleyFlow/shithub/internal/infra/db" | |
| 13 | 20 | ) |
| 14 | 21 | |
| 15 | 22 | // adminActionsCmd is the parent group for actions-related operator |
@@ -80,7 +87,116 @@ Exit code 0 = clean parse, 2 = Error-severity diagnostics produced, | ||
| 80 | 87 | }, |
| 81 | 88 | } |
| 82 | 89 | |
| 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 | + | |
| 83 | 198 | func init() { |
| 84 | 199 | adminActionsCmd.AddCommand(adminActionsParseCmd) |
| 200 | + adminActionsCmd.AddCommand(newAdminActionsCancelAllCmd()) | |
| 85 | 201 | adminCmd.AddCommand(adminActionsCmd) |
| 86 | 202 | } |
docs/internal/actions-schema.mdmodified68 lines changed — click to load
@@ -130,19 +130,25 @@ v1 supports four triggers — anything else is a parse error. | ||
| 130 | 130 | |
| 131 | 131 | ### `uses:` allowlist |
| 132 | 132 | |
| 133 | -Exactly three aliases, no exceptions: | |
| 133 | +Exactly three aliases are reserved at parse time, no exceptions: | |
| 134 | 134 | |
| 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 | | |
| 140 | 140 | |
| 141 | 141 | Any other `uses:` value (community actions, Docker images, composite |
| 142 | 142 | actions) is an Error-severity diagnostic. The marketplace problem is |
| 143 | 143 | explicitly out of scope for v1; revisit only if a real demand exists |
| 144 | 144 | and we have an answer for supply-chain trust. |
| 145 | 145 | |
| 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 | + | |
| 146 | 152 | ### File-size + parser caps |
| 147 | 153 | |
| 148 | 154 | - **64 KB** workflow file size cap (`workflow.MaxWorkflowFileBytes`). |
@@ -562,6 +568,29 @@ defer to S41g where the lifecycle work touches that surface anyway. | ||
| 562 | 568 | ref defaults to the repo's default branch). Returns 204 No Content |
| 563 | 569 | on success. Synchronous trigger.Enqueue (no discovery — file is |
| 564 | 570 | 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. | |
| 565 | 594 | |
| 566 | 595 | ### What S41b deliberately doesn't do |
| 567 | 596 | |
@@ -572,8 +601,6 @@ defer to S41g where the lifecycle work touches that surface anyway. | ||
| 572 | 601 | but no caller produces them yet. S41b-2 adds the sweep + the |
| 573 | 602 | `robfig/cron/v3` dep + `shithubd-cron.service` wiring. |
| 574 | 603 | - External-PR triggers. Conservative collaborator gate above. |
| 575 | -- `workflow_run` webhook events. S41h adds the webhook event family | |
| 576 | - + atom feed. | |
| 577 | 604 | |
| 578 | 605 | ## Secrets + variables settings surface (S41c) |
| 579 | 606 | |
docs/internal/billing.mdadded201 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.mdmodified25 lines changed — click to load
@@ -10,6 +10,7 @@ hook. | ||
| 10 | 10 | | ------------------------------------------------------- | ----------------------------- | |
| 11 | 11 | | `GET /{owner}/{repo}/branches?filter=active|stale|` | `branchesList` | |
| 12 | 12 | | `GET /{owner}/{repo}/tags` | `tagsList` | |
| 13 | +| `GET /{owner}/{repo}/compare` | `compareView` | | |
| 13 | 14 | | `GET /{owner}/{repo}/compare/{base}...{head}` | `compareView` | |
| 14 | 15 | | `GET /{owner}/{repo}/settings/branches` | `settingsBranches` (auth-gated) | |
| 15 | 16 | | `POST /{owner}/{repo}/settings/branches` | upsert rule | |
@@ -50,10 +51,14 @@ first-class releases ship post-MVP. | ||
| 50 | 51 | ## Compare view |
| 51 | 52 | |
| 52 | 53 | 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`. | |
| 57 | 62 | - The commits-list (head-side only) via |
| 58 | 63 | `repogit.CommitsBetween(base, head, 250)`. |
| 59 | 64 | - The three-dot diff via S19's renderer fed from |
docs/internal/index.mdmodified8 lines changed — click to load
@@ -58,6 +58,8 @@ site. | ||
| 58 | 58 | - [actions-schema.md](./actions-schema.md), |
| 59 | 59 | [actions-runner-api.md](./actions-runner-api.md) |
| 60 | 60 | - [orgs.md](./orgs.md), [teams.md](./teams.md) |
| 61 | +- [billing.md](./billing.md) — paid org product contract, | |
| 62 | + entitlements, and Stripe integration guardrails. | |
| 61 | 63 | - [notifications.md](./notifications.md) |
| 62 | 64 | - [search.md](./search.md), [markdown.md](./markdown.md) |
| 63 | 65 | - [seo.md](./seo.md) — crawler endpoints, metadata, sitemap, and |
docs/internal/orgs.mdmodified18 lines changed — click to load
@@ -182,6 +182,18 @@ Soft-deleted users/orgs are dropped from `principals` so their slug | ||
| 182 | 182 | becomes available — the username_redirects table still preserves the |
| 183 | 183 | old slug for 301s during the rename cooldown. |
| 184 | 184 | |
| 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 | + | |
| 185 | 197 | ## What we deferred from the spec |
| 186 | 198 | |
| 187 | 199 | * **`username_redirects` rename to `principal_redirects`**. The |
docs/internal/permissions.mdmodified53 lines changed — click to load
@@ -107,6 +107,30 @@ the verdict. Ordered from most-decisive to least: | ||
| 107 | 107 | 10. **Login-required actions** (star/fork) on anonymous → deny |
| 108 | 108 | (`DenyAnonymous`). |
| 109 | 109 | |
| 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 | + | |
| 110 | 134 | ## Existence-leak guard |
| 111 | 135 | |
| 112 | 136 | `policy.Maybe404(decision, repo, actor)` maps a denial to a status |
@@ -147,10 +171,10 @@ that constructs an actor must source it correctly: | ||
| 147 | 171 | suspending an account takes effect on the user's next click. |
| 148 | 172 | * **Web (PAT)** — `middleware.PATAuthMiddleware` rejects requests |
| 149 | 173 | 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. | |
| 154 | 178 | * **git over HTTPS (`internal/web/handlers/githttp`)** — the basic- |
| 155 | 179 | auth resolver (`auth.go::resolveViaPAT`/`resolveViaPassword`) |
| 156 | 180 | rejects suspended owners with `errBadCredentials` *before* the |
@@ -212,3 +236,9 @@ the policy actor at the lookup wrapper): | ||
| 212 | 236 | Test files everywhere are exempt — they legitimately seed state. If a |
| 213 | 237 | new pattern surfaces (e.g. an issue handler reads `issue.author_id`), |
| 214 | 238 | 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.mdmodified30 lines changed — click to load
@@ -52,8 +52,12 @@ so a self-merge can't be opened. Cross-fork PRs land in S27. | ||
| 52 | 52 | | `POST /{owner}/{repo}/pulls/{number}/ready` | RequireUser | |
| 53 | 53 | | `POST /{owner}/{repo}/pulls/{number}/merge` | RequireUser | |
| 54 | 54 | |
| 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. | |
| 57 | 61 | |
| 58 | 62 | ## Auto-synchronize on head push |
| 59 | 63 | |
@@ -148,6 +152,16 @@ noreply emails are post-MVP. | ||
| 148 | 152 | |
| 149 | 153 | ## Web UI |
| 150 | 154 | |
| 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. | |
| 151 | 165 | - Tabbed view at `/pulls/{number}` switches between Conversation, |
| 152 | 166 | Commits, Files, Checks via the `Tab` field on the template data. |
| 153 | 167 | - Conversation follows GitHub's PageHeader + tab strip shape: state |
docs/internal/runbooks/actions.mdmodified149 lines changed — click to load
@@ -1,5 +1,72 @@ | ||
| 1 | 1 | # Actions runbook |
| 2 | 2 | |
| 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 | + | |
| 3 | 70 | ## Live log tail |
| 4 | 71 | |
| 5 | 72 | 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 | ||
| 13 | 80 | `?after=<seq>` for the first connection from a rendered log page. A terminal |
| 14 | 81 | step sends `event: done` and closes the stream. |
| 15 | 82 | |
| 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 | + | |
| 16 | 88 | Log chunks are never sent through Postgres `NOTIFY`. Runner log writes append |
| 17 | 89 | to `workflow_step_log_chunks`, then `NOTIFY step_log_<step_id>` with only the |
| 18 | 90 | sequence number. Step completion notifies `done`. |
@@ -39,3 +111,66 @@ contains that route and reload Caddy: | ||
| 39 | 111 | ```sh |
| 40 | 112 | sudo caddy reload --config /etc/caddy/Caddyfile |
| 41 | 113 | ``` |
| 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.mdmodified7 lines changed — click to load
@@ -35,6 +35,7 @@ document. | ||
| 35 | 35 | | Org suspension blocks writes | `policy.Can` + `DenyOrgSuspended` | S30 | |
| 36 | 36 | | Repo soft-delete blocks all actions | `policy.Can` + `DenyRepoDeleted` | S15 | |
| 37 | 37 | | 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 | | |
| 38 | 39 | |
| 39 | 40 | ## Input handling |
| 40 | 41 | |
docs/public/SUMMARY.mdmodified15 lines changed — click to load
@@ -13,6 +13,7 @@ | ||
| 13 | 13 | - [Issues](./user/issues.md) |
| 14 | 14 | - [Pull requests](./user/pull-requests.md) |
| 15 | 15 | - [Branch protection & reviews](./user/branch-protection.md) |
| 16 | +- [Actions](./user/actions.md) | |
| 16 | 17 | - [Notifications](./user/notifications.md) |
| 17 | 18 | - [Webhooks](./user/webhooks.md) |
| 18 | 19 | - [Search](./user/search.md) |
@@ -28,6 +29,8 @@ | ||
| 28 | 29 | - [Issues](./api/issues.md) |
| 29 | 30 | - [Pull requests](./api/pulls.md) |
| 30 | 31 | - [Status checks](./api/checks.md) |
| 32 | +- [Actions workflow API](./api/actions.md) | |
| 33 | +- [Actions runner API](./api/actions-runner.md) | |
| 31 | 34 | - [Webhooks](./api/webhooks.md) |
| 32 | 35 | - [Search](./api/search.md) |
| 33 | 36 | - [Admin (site-admin only)](./api/admin.md) |
docs/public/api/actions.mdadded65 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.mdmodified95 lines changed — click to load
@@ -8,7 +8,8 @@ instead of PATs. | ||
| 8 | 8 | > **Status.** The API is intentionally narrow today. Endpoints |
| 9 | 9 | > currently shipped: `GET /api/v1/user`, the |
| 10 | 10 | > `/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 | |
| 12 | 13 | > reference (Issues, Pull requests, Webhooks, etc.) describe the |
| 13 | 14 | > **planned** shape and will land in subsequent sprints. Pages |
| 14 | 15 | > that document planned-only endpoints carry a banner. |
@@ -28,17 +29,30 @@ Authorization: Bearer shp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | ||
| 28 | 29 | `Authorization: token shp_…` is also accepted as a synonym. |
| 29 | 30 | |
| 30 | 31 | 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. | |
| 32 | 36 | |
| 33 | 37 | ```json |
| 34 | -{"error": "unauthenticated"} | |
| 38 | +{"error": "invalid token"} | |
| 35 | 39 | ``` |
| 36 | 40 | |
| 37 | 41 | 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: | |
| 39 | 48 | |
| 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"} | |
| 42 | 56 | ``` |
| 43 | 57 | |
| 44 | 58 | ## Scopes |
@@ -59,15 +73,47 @@ to a repo the user has no access to. | ||
| 59 | 73 | conventional HTTP status. |
| 60 | 74 | - **Cache-Control:** every API response sets `no-store`. |
| 61 | 75 | - **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:` | |
| 65 | 82 | header. |
| 66 | 83 | - **Body cap:** request bodies are capped at 256 KiB. Larger |
| 67 | 84 | payloads return `413`. |
| 68 | 85 | - **Rate limits:** every response includes `X-RateLimit-Limit`, |
| 69 | 86 | `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. | |
| 71 | 117 | |
| 72 | 118 | ## Versioning |
| 73 | 119 | |
docs/public/api/webhooks.mdmodified20 lines changed — click to load
@@ -45,6 +45,9 @@ The events shippable today, by `X-Shithub-Event` header: | ||
| 45 | 45 | - `check_run` (actions: `created`, `completed`, `rerequested`) |
| 46 | 46 | - `check_suite` (actions: `requested`, `completed`, |
| 47 | 47 | `rerequested`) |
| 48 | +- `workflow_run` (actions: `queued`, `running`, `completed`) | |
| 49 | +- `workflow_job` (actions: `queued`, `running`, `completed`, | |
| 50 | + `cancelled`) | |
| 48 | 51 | - `star` |
| 49 | 52 | - `fork` |
| 50 | 53 | - `repository` (actions: `created`, `deleted`, `archived`, |
@@ -55,3 +58,11 @@ The events shippable today, by `X-Shithub-Event` header: | ||
| 55 | 58 | Each event's payload is documented per-type in the webhook detail |
| 56 | 59 | page's "Recent deliveries" inspector — that's currently the |
| 57 | 60 | 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.mdadded92 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.mdmodified27 lines changed — click to load
@@ -21,7 +21,8 @@ Repository → Settings → Webhooks → "Add webhook". | ||
| 21 | 21 | |
| 22 | 22 | Each delivery includes: |
| 23 | 23 | |
| 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`. | |
| 25 | 26 | - `X-Shithub-Delivery: <uuid>` — unique per delivery (idempotent). |
| 26 | 27 | - `X-Shithub-Signature-256: sha256=<hex>` — HMAC-SHA256 of the |
| 27 | 28 | raw body using your configured secret. |
@@ -105,6 +106,18 @@ Webhook detail page → "Recent deliveries". Each row shows: | ||
| 105 | 106 | Stored bodies are capped at 32 KiB (your endpoint can accept |
| 106 | 107 | bigger; we just don't keep more for the inspector). |
| 107 | 108 | |
| 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 | + | |
| 108 | 121 | ## SSRF defense |
| 109 | 122 | |
| 110 | 123 | shithub validates webhook URLs server-side: hostnames are |
internal/actions/events/emit.goadded143 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.goadded83 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.gomodified60 lines changed — click to load
@@ -12,6 +12,7 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5" |
| 13 | 13 | |
| 14 | 14 | "github.com/tenseleyFlow/shithub/internal/actions/checksync" |
| 15 | + actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events" | |
| 15 | 16 | "github.com/tenseleyFlow/shithub/internal/actions/runstate" |
| 16 | 17 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 17 | 18 | "github.com/tenseleyFlow/shithub/internal/infra/metrics" |
@@ -73,6 +74,13 @@ func CancelRun(ctx context.Context, deps Deps, runID int64, reason string) (Canc | ||
| 73 | 74 | if err != nil { |
| 74 | 75 | return CancelResult{}, err |
| 75 | 76 | } |
| 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 | + } | |
| 76 | 84 | } |
| 77 | 85 | if err := tx.Commit(ctx); err != nil { |
| 78 | 86 | return CancelResult{}, err |
@@ -138,6 +146,13 @@ func CancelJob(ctx context.Context, deps Deps, jobID int64, reason string) (Canc | ||
| 138 | 146 | if err != nil { |
| 139 | 147 | return CancelResult{}, err |
| 140 | 148 | } |
| 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 | + } | |
| 141 | 156 | } |
| 142 | 157 | if err := tx.Commit(ctx); err != nil { |
| 143 | 158 | return CancelResult{}, err |
@@ -161,6 +176,27 @@ func recordCancelledJobs(jobs []actionsdb.WorkflowJob, reason string) { | ||
| 161 | 176 | metrics.ActionsJobsCancelledTotal.WithLabelValues(cancelReason(reason)).Add(float64(len(jobs))) |
| 162 | 177 | } |
| 163 | 178 | |
| 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 | + | |
| 164 | 200 | func cancelReason(reason string) string { |
| 165 | 201 | switch strings.TrimSpace(reason) { |
| 166 | 202 | case CancelReasonUser: |
internal/actions/lifecycle/cancel_test.gomodified95 lines changed — click to load
@@ -146,12 +146,67 @@ func TestCancelJobRequestsRunningJobWithoutTerminalOverwrite(t *testing.T) { | ||
| 146 | 146 | } |
| 147 | 147 | } |
| 148 | 148 | |
| 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 | + | |
| 149 | 199 | 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) { | |
| 150 | 205 | t.Helper() |
| 151 | 206 | ctx := context.Background() |
| 152 | 207 | user, err := usersdb.New().CreateUser(ctx, db, usersdb.CreateUserParams{ |
| 153 | - Username: "alice", | |
| 154 | - DisplayName: "Alice", | |
| 208 | + Username: username, | |
| 209 | + DisplayName: username, | |
| 155 | 210 | PasswordHash: fixtureHash, |
| 156 | 211 | }) |
| 157 | 212 | if err != nil { |
@@ -159,7 +214,7 @@ func setupLifecycleRepo(t *testing.T, db actionsdb.DBTX) (repoID, userID int64) | ||
| 159 | 214 | } |
| 160 | 215 | repo, err := reposdb.New().CreateRepo(ctx, db, reposdb.CreateRepoParams{ |
| 161 | 216 | OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true}, |
| 162 | - Name: "demo", | |
| 217 | + Name: repoName, | |
| 163 | 218 | DefaultBranch: "trunk", |
| 164 | 219 | Visibility: reposdb.RepoVisibilityPublic, |
| 165 | 220 | }) |
@@ -169,6 +224,18 @@ func setupLifecycleRepo(t *testing.T, db actionsdb.DBTX) (repoID, userID int64) | ||
| 169 | 224 | return repo.ID, user.ID |
| 170 | 225 | } |
| 171 | 226 | |
| 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 | + | |
| 172 | 239 | func insertLifecycleRun(t *testing.T, db actionsdb.DBTX, repoID, userID, runIndex int64) actionsdb.WorkflowRun { |
| 173 | 240 | t.Helper() |
| 174 | 241 | run, err := actionsdb.New().InsertWorkflowRun(context.Background(), db, actionsdb.InsertWorkflowRunParams{ |
internal/actions/logstream/logstream.gomodified11 lines changed — click to load
@@ -33,6 +33,11 @@ func ListenSQL(stepID int64) string { | ||
| 33 | 33 | return "LISTEN " + pgx.Identifier{Channel(stepID)}.Sanitize() |
| 34 | 34 | } |
| 35 | 35 | |
| 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 | + | |
| 36 | 41 | // NotifyChunk wakes log tailers for a newly-persisted chunk. |
| 37 | 42 | func NotifyChunk(ctx context.Context, db DBTX, stepID int64, seq int32) error { |
| 38 | 43 | return notify(ctx, db, stepID, strconv.FormatInt(int64(seq), 10)) |
internal/actions/logstream/logstream_test.gomodified9 lines changed — click to load
@@ -12,6 +12,9 @@ func TestChannelAndListenSQL(t *testing.T) { | ||
| 12 | 12 | if got := ListenSQL(42); got != `LISTEN "step_log_42"` { |
| 13 | 13 | t.Fatalf("ListenSQL=%q", got) |
| 14 | 14 | } |
| 15 | + if got := UnlistenSQL(42); got != `UNLISTEN "step_log_42"` { | |
| 16 | + t.Fatalf("UnlistenSQL=%q", got) | |
| 17 | + } | |
| 15 | 18 | } |
| 16 | 19 | |
| 17 | 20 | func TestParsePayload(t *testing.T) { |
internal/actions/queries/workflow_runs.sqlmodified37 lines changed — click to load
@@ -109,6 +109,19 @@ SET status = 'running', | ||
| 109 | 109 | updated_at = now() |
| 110 | 110 | WHERE id = $1 AND status = 'queued'; |
| 111 | 111 | |
| 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 | + | |
| 112 | 125 | -- name: CompleteWorkflowRun :one |
| 113 | 126 | UPDATE workflow_runs |
| 114 | 127 | SET status = 'completed', |
@@ -163,6 +176,18 @@ WHERE r.repo_id = sqlc.arg(repo_id)::bigint | ||
| 163 | 176 | AND (sqlc.narg(conclusion)::check_conclusion IS NULL OR r.conclusion = sqlc.narg(conclusion)::check_conclusion) |
| 164 | 177 | AND (sqlc.narg(actor_username)::text IS NULL OR u.username = sqlc.narg(actor_username)::citext); |
| 165 | 178 | |
| 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 | + | |
| 166 | 191 | -- name: ListWorkflowRunWorkflowsForRepo :many |
| 167 | 192 | WITH ranked AS ( |
| 168 | 193 | SELECT workflow_file, |
internal/actions/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/actions/sqlc/querier.gomodified14 lines changed — click to load
@@ -65,6 +65,7 @@ type Querier interface { | ||
| 65 | 65 | InsertWorkflowRun(ctx context.Context, db DBTX, arg InsertWorkflowRunParams) (WorkflowRun, error) |
| 66 | 66 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 67 | 67 | InsertWorkflowStep(ctx context.Context, db DBTX, arg InsertWorkflowStepParams) (WorkflowStep, error) |
| 68 | + ListActiveWorkflowRunsForAdmin(ctx context.Context, db DBTX, arg ListActiveWorkflowRunsForAdminParams) ([]WorkflowRun, error) | |
| 68 | 69 | ListAllStepLogChunksForStep(ctx context.Context, db DBTX, stepID int64) ([]WorkflowStepLogChunk, error) |
| 69 | 70 | ListArtifactsForRun(ctx context.Context, db DBTX, runID int64) ([]ListArtifactsForRunRow, error) |
| 70 | 71 | // Older queued/running runs with the same group block the new run while they |
@@ -102,6 +103,7 @@ type Querier interface { | ||
| 102 | 103 | RequestWorkflowJobCancel(ctx context.Context, db DBTX, id int64) (WorkflowJob, error) |
| 103 | 104 | RequestWorkflowRunCancel(ctx context.Context, db DBTX, runID int64) ([]WorkflowJob, error) |
| 104 | 105 | RevokeAllTokensForRunner(ctx context.Context, db DBTX, runnerID int64) error |
| 106 | + StartWorkflowRun(ctx context.Context, db DBTX, id int64) (WorkflowRun, error) | |
| 105 | 107 | TouchRunnerHeartbeat(ctx context.Context, db DBTX, arg TouchRunnerHeartbeatParams) error |
| 106 | 108 | UpdateStepLogChunk(ctx context.Context, db DBTX, arg UpdateStepLogChunkParams) error |
| 107 | 109 | UpdateWorkflowJobStatus(ctx context.Context, db DBTX, arg UpdateWorkflowJobStatusParams) (WorkflowJob, error) |
internal/actions/sqlc/workflow_runs.sql.gomodified116 lines changed — click to load
@@ -398,6 +398,68 @@ func (q *Queries) InsertWorkflowRun(ctx context.Context, db DBTX, arg InsertWork | ||
| 398 | 398 | return i, err |
| 399 | 399 | } |
| 400 | 400 | |
| 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 | + | |
| 401 | 463 | const listBlockingConcurrencyRunsForUpdate = `-- name: ListBlockingConcurrencyRunsForUpdate :many |
| 402 | 464 | SELECT r.id, r.repo_id, r.run_index, r.workflow_file, r.workflow_name, |
| 403 | 465 | 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 | ||
| 698 | 760 | err := row.Scan(&next_index) |
| 699 | 761 | return next_index, err |
| 700 | 762 | } |
| 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.gomodified84 lines changed — click to load
@@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/actions/checksync" |
| 17 | 17 | "github.com/tenseleyFlow/shithub/internal/actions/concurrency" |
| 18 | + actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events" | |
| 18 | 19 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 19 | 20 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 20 | 21 | "github.com/tenseleyFlow/shithub/internal/checks" |
@@ -184,6 +185,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) { | ||
| 184 | 185 | } |
| 185 | 186 | return Result{}, fmt.Errorf("trigger: insert run: %w", err) |
| 186 | 187 | } |
| 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 | + } | |
| 187 | 191 | |
| 188 | 192 | // Persist child jobs + their steps. Order in Workflow.Jobs is YAML |
| 189 | 193 | // document order, which we preserve via job_index. |
@@ -218,6 +222,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) { | ||
| 218 | 222 | return Result{}, fmt.Errorf("trigger: insert job %s: %w", j.Key, err) |
| 219 | 223 | } |
| 220 | 224 | 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 | + } | |
| 221 | 228 | |
| 222 | 229 | for si, s := range j.Steps { |
| 223 | 230 | stepEnvJSON, err := marshalEnv(s.Env) |
@@ -253,6 +260,9 @@ func Enqueue(ctx context.Context, deps Deps, p EnqueueParams) (Result, error) { | ||
| 253 | 260 | if err != nil { |
| 254 | 261 | return Result{}, fmt.Errorf("trigger: enforce concurrency: %w", err) |
| 255 | 262 | } |
| 263 | + if err := emitConcurrencyCancelEvents(ctx, tx, q, concurrencyResult.CancelledJobs); err != nil { | |
| 264 | + return Result{}, err | |
| 265 | + } | |
| 256 | 266 | |
| 257 | 267 | if err := tx.Commit(ctx); err != nil { |
| 258 | 268 | return Result{}, fmt.Errorf("trigger: commit run tx: %w", err) |
@@ -348,6 +358,50 @@ func lookupExistingRun(ctx context.Context, pool *pgxpool.Pool, p EnqueueParams) | ||
| 348 | 358 | return rows, nil |
| 349 | 359 | } |
| 350 | 360 | |
| 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 | + | |
| 351 | 405 | func pgInt8(v int64) pgtype.Int8 { |
| 352 | 406 | return pgtype.Int8{Int64: v, Valid: v != 0} |
| 353 | 407 | } |
internal/actions/trigger/enqueue_test.gomodified36 lines changed — click to load
@@ -143,6 +143,10 @@ func TestEnqueue_HappyPath(t *testing.T) { | ||
| 143 | 143 | if run.Status != actionsdb.WorkflowRunStatusQueued { |
| 144 | 144 | t.Errorf("status: got %s want queued", run.Status) |
| 145 | 145 | } |
| 146 | + assertDomainEventCounts(t, f.pool, f.repoID, map[string]int64{ | |
| 147 | + "workflow_run": 1, | |
| 148 | + "workflow_job": 1, | |
| 149 | + }) | |
| 146 | 150 | } |
| 147 | 151 | |
| 148 | 152 | func TestEnqueue_ResolvesConcurrencyGroupExpression(t *testing.T) { |
@@ -235,6 +239,26 @@ func TestEnqueue_CancelInProgressCancelsOlderQueuedRun(t *testing.T) { | ||
| 235 | 239 | if newRun.Status != actionsdb.WorkflowRunStatusQueued { |
| 236 | 240 | t.Fatalf("new run status: got %s want queued", newRun.Status) |
| 237 | 241 | } |
| 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 | + } | |
| 238 | 262 | } |
| 239 | 263 | |
| 240 | 264 | func TestClaimQueuedWorkflowJob_BlocksYoungerConcurrencyRun(t *testing.T) { |
internal/admin/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/auth/audit/audit.gomodified17 lines changed — click to load
@@ -92,6 +92,17 @@ const ( | ||
| 92 | 92 | ActionActionsVariableSet Action = "actions_variable_set" |
| 93 | 93 | ActionActionsVariableDeleted Action = "actions_variable_deleted" |
| 94 | 94 | |
| 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 | + | |
| 95 | 106 | // S34 — site admin actions. Always recorded with the real admin's |
| 96 | 107 | // id in actor_id; impersonation flows additionally carry the |
| 97 | 108 | // impersonated user's id in meta.impersonated_user_id. |
internal/auth/policy/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/billing/billing.goadded309 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.goadded208 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.sqladded291 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.goadded865 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.goadded25 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 @@ | ||
| 2 | 2 | // versions: |
| 3 | 3 | // sqlc v1.31.1 |
| 4 | 4 | |
| 5 | -package actionsdb | |
| 5 | +package billingdb | |
| 6 | 6 | |
| 7 | 7 | import ( |
| 8 | 8 | "database/sql/driver" |
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/billing/sqlc/querier.goadded32 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.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/infra/config/config.gomodified56 lines changed — click to load
@@ -38,6 +38,26 @@ type Config struct { | ||
| 38 | 38 | Storage StorageConfig `toml:"storage"` |
| 39 | 39 | Auth AuthConfig `toml:"auth"` |
| 40 | 40 | 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"` | |
| 41 | 61 | } |
| 42 | 62 | |
| 43 | 63 | // NotifConfig configures the S29 notification surface. UnsubscribeKeyB64 |
@@ -205,6 +225,12 @@ func Defaults() Config { | ||
| 205 | 225 | ForcePathStyle: true, |
| 206 | 226 | }, |
| 207 | 227 | }, |
| 228 | + RateLimit: RateLimitConfig{ | |
| 229 | + API: APIRateLimitConfig{ | |
| 230 | + AuthedPerHour: 5000, | |
| 231 | + AnonPerHour: 60, | |
| 232 | + }, | |
| 233 | + }, | |
| 208 | 234 | Auth: AuthConfig{ |
| 209 | 235 | RequireEmailVerification: true, |
| 210 | 236 | BaseURL: "http://127.0.0.1:8080", |
@@ -321,6 +347,18 @@ func Validate(c *Config) error { | ||
| 321 | 347 | if c.Auth.EmailFrom == "" { |
| 322 | 348 | return errors.New("config: auth.email_from is required") |
| 323 | 349 | } |
| 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 | + } | |
| 324 | 362 | return nil |
| 325 | 363 | } |
| 326 | 364 | |
internal/infra/config/config_test.gomodified62 lines changed — click to load
@@ -108,3 +108,62 @@ func TestMergeFlags_OverridesEnv(t *testing.T) { | ||
| 108 | 108 | t.Errorf("Web.Addr: got %q, want :7777", cfg.Web.Addr) |
| 109 | 109 | } |
| 110 | 110 | } |
| 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.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/meta/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/migrationsfs/migrations/0061_billing_domain.sqladded252 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.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/orgs/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/pulls/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/ratelimit/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/repos/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/users/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/web/auth_wiring.gomodified19 lines changed — click to load
@@ -28,6 +28,7 @@ import ( | ||
| 28 | 28 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 29 | 29 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 30 | 30 | apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api" |
| 31 | + "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit" | |
| 31 | 32 | authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth" |
| 32 | 33 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 33 | 34 | "github.com/tenseleyFlow/shithub/internal/web/render" |
@@ -68,6 +69,12 @@ func buildAPIHandlers( | ||
| 68 | 69 | RunnerJWT: runnerJWT, |
| 69 | 70 | SecretBox: secretBox, |
| 70 | 71 | 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 | + }, | |
| 71 | 78 | }) |
| 72 | 79 | } |
| 73 | 80 | |
internal/web/embed_test.gomodified34 lines changed — click to load
@@ -120,6 +120,34 @@ func TestOrgPagesRenderSingleSharedOrgNav(t *testing.T) { | ||
| 120 | 120 | } |
| 121 | 121 | } |
| 122 | 122 | |
| 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 | + | |
| 123 | 151 | // errorOriginatesInPartial returns true when an html/template execute |
| 124 | 152 | // error blames a file whose basename starts with `_`. Errors from such |
| 125 | 153 | // files are bugs in the partial because we render with an empty map |
internal/web/handlers/api/actions_cancel.gomodified23 lines changed — click to load
@@ -39,7 +39,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) { | ||
| 39 | 39 | writeAPIError(w, http.StatusNotFound, "job not found") |
| 40 | 40 | return |
| 41 | 41 | } |
| 42 | - job, run, repo, ok := h.resolveCancellableJob(w, r, auth.UserID, jobID) | |
| 42 | + job, run, repo, ok := h.resolveCancellableJob(w, r, auth.PolicyActor(), jobID) | |
| 43 | 43 | if !ok { |
| 44 | 44 | return |
| 45 | 45 | } |
@@ -65,7 +65,7 @@ func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) { | ||
| 65 | 65 | func (h *Handlers) resolveCancellableJob( |
| 66 | 66 | w http.ResponseWriter, |
| 67 | 67 | r *http.Request, |
| 68 | - userID int64, | |
| 68 | + actor policy.Actor, | |
| 69 | 69 | jobID int64, |
| 70 | 70 | ) (actionsdb.WorkflowJob, actionsdb.WorkflowRun, reposdb.Repo, bool) { |
| 71 | 71 | q := actionsdb.New() |
@@ -92,7 +92,6 @@ func (h *Handlers) resolveCancellableJob( | ||
| 92 | 92 | } |
| 93 | 93 | return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false |
| 94 | 94 | } |
| 95 | - actor := policy.UserActor(userID, "", false, false) | |
| 96 | 95 | if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow { |
| 97 | 96 | writeAPIError(w, http.StatusNotFound, "job not found") |
| 98 | 97 | return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false |
internal/web/handlers/api/actions_rerun.gomodified23 lines changed — click to load
@@ -28,7 +28,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) { | ||
| 28 | 28 | writeAPIError(w, http.StatusNotFound, "run not found") |
| 29 | 29 | return |
| 30 | 30 | } |
| 31 | - run, repo, ok := h.resolveLifecycleRun(w, r, auth.UserID, runID) | |
| 31 | + run, repo, ok := h.resolveLifecycleRun(w, r, auth.PolicyActor(), runID) | |
| 32 | 32 | if !ok { |
| 33 | 33 | return |
| 34 | 34 | } |
@@ -54,7 +54,7 @@ func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) { | ||
| 54 | 54 | func (h *Handlers) resolveLifecycleRun( |
| 55 | 55 | w http.ResponseWriter, |
| 56 | 56 | r *http.Request, |
| 57 | - userID int64, | |
| 57 | + actor policy.Actor, | |
| 58 | 58 | runID int64, |
| 59 | 59 | ) (actionsdb.WorkflowRun, reposdb.Repo, bool) { |
| 60 | 60 | q := actionsdb.New() |
@@ -72,7 +72,6 @@ func (h *Handlers) resolveLifecycleRun( | ||
| 72 | 72 | } |
| 73 | 73 | return actionsdb.WorkflowRun{}, reposdb.Repo{}, false |
| 74 | 74 | } |
| 75 | - actor := policy.UserActor(userID, "", false, false) | |
| 76 | 75 | if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow { |
| 77 | 76 | writeAPIError(w, http.StatusNotFound, "run not found") |
| 78 | 77 | return actionsdb.WorkflowRun{}, reposdb.Repo{}, false |
internal/web/handlers/api/api.gomodified48 lines changed — click to load
@@ -24,6 +24,7 @@ import ( | ||
| 24 | 24 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 25 | 25 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 26 | 26 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 27 | + "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit" | |
| 27 | 28 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 28 | 29 | ) |
| 29 | 30 | |
@@ -38,6 +39,12 @@ type Deps struct { | ||
| 38 | 39 | RunnerJWT *runnerjwt.Signer |
| 39 | 40 | SecretBox *secretbox.Box |
| 40 | 41 | 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 | |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | 50 | // Handlers is the registered API handler set. Construct with New. |
@@ -77,9 +84,20 @@ const runnerAPIMaxBodyBytes = 768 * 1024 | ||
| 77 | 84 | |
| 78 | 85 | // Mount registers /api/v1/* on r. Caller is responsible for putting r |
| 79 | 86 | // 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. | |
| 80 | 92 | 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 | + }) | |
| 81 | 98 | r.Group(func(r chi.Router) { |
| 82 | 99 | r.Use(middleware.MaxBodySize(runnerAPIMaxBodyBytes)) |
| 100 | + r.Use(apiLimitMW) | |
| 83 | 101 | h.mountRunners(r) |
| 84 | 102 | }) |
| 85 | 103 | r.Group(func(r chi.Router) { |
@@ -88,6 +106,9 @@ func (h *Handlers) Mount(r chi.Router) { | ||
| 88 | 106 | Pool: h.d.Pool, |
| 89 | 107 | Debouncer: h.d.Debouncer, |
| 90 | 108 | })) |
| 109 | + r.Use(apiLimitMW) | |
| 110 | + // /meta is capability discovery — no scope required, anon ok. | |
| 111 | + h.mountMeta(r) | |
| 91 | 112 | r.Group(func(r chi.Router) { |
| 92 | 113 | r.Use(middleware.RequireScope(pat.ScopeUserRead)) |
| 93 | 114 | r.Get("/api/v1/user", h.userMe) |
internal/web/handlers/api/apilimit/apilimit.goadded105 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.goadded171 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.goadded256 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.gomodified9 lines changed — click to load
@@ -60,8 +60,7 @@ func (h *Handlers) resolveAPIRepo(w http.ResponseWriter, r *http.Request, action | ||
| 60 | 60 | writeAPIError(w, http.StatusNotFound, "repo not found") |
| 61 | 61 | return nil, false |
| 62 | 62 | } |
| 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 { | |
| 65 | 64 | // Existence-leak: 404 instead of 403 when the actor can't see |
| 66 | 65 | // the repo. The PAT-scope check above is the public 403; this |
| 67 | 66 | // is the visibility gate. |
internal/web/handlers/api/cross_test.goadded280 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.goadded51 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.gomodified149 lines changed — click to load
@@ -20,6 +20,7 @@ import ( | ||
| 20 | 20 | "github.com/jackc/pgx/v5" |
| 21 | 21 | "github.com/jackc/pgx/v5/pgtype" |
| 22 | 22 | |
| 23 | + actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events" | |
| 23 | 24 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" |
| 24 | 25 | actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle" |
| 25 | 26 | "github.com/tenseleyFlow/shithub/internal/actions/logstream" |
@@ -220,7 +221,21 @@ func (h *Handlers) claimRunnerJob( | ||
| 220 | 221 | committed = true |
| 221 | 222 | return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, nil |
| 222 | 223 | } |
| 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 { | |
| 224 | 239 | return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err |
| 225 | 240 | } |
| 226 | 241 | steps, err := q.ListRunnerStepsForJob(ctx, tx, job.ID) |
@@ -733,15 +748,45 @@ func (h *Handlers) applyJobStatus( | ||
| 733 | 748 | return actionsdb.WorkflowJob{}, false, "", err |
| 734 | 749 | } |
| 735 | 750 | 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 | |
| 736 | 758 | if complete { |
| 737 | - if _, err := q.CompleteWorkflowRun(ctx, tx, actionsdb.CompleteWorkflowRunParams{ | |
| 759 | + runAfter, err = q.CompleteWorkflowRun(ctx, tx, actionsdb.CompleteWorkflowRunParams{ | |
| 738 | 760 | ID: updated.RunID, |
| 739 | 761 | 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 { | |
| 741 | 788 | return actionsdb.WorkflowJob{}, false, "", err |
| 742 | 789 | } |
| 743 | - } else if err := q.MarkWorkflowRunRunning(ctx, tx, updated.RunID); err != nil { | |
| 744 | - return actionsdb.WorkflowJob{}, false, "", err | |
| 745 | 790 | } |
| 746 | 791 | if err := tx.Commit(ctx); err != nil { |
| 747 | 792 | return actionsdb.WorkflowJob{}, false, "", err |
@@ -755,6 +800,71 @@ func (h *Handlers) applyJobStatus( | ||
| 755 | 800 | return updated, complete, runConclusion, nil |
| 756 | 801 | } |
| 757 | 802 | |
| 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 | + | |
| 758 | 868 | func deriveWorkflowRunConclusion(jobs []actionsdb.ListJobsForRunRow) (actionsdb.CheckConclusion, bool) { |
| 759 | 869 | if len(jobs) == 0 { |
| 760 | 870 | return actionsdb.CheckConclusionFailure, true |
internal/web/handlers/api/stars.gomodified12 lines changed — click to load
@@ -122,11 +122,7 @@ func (h *Handlers) resolveStarTargetRepo(w http.ResponseWriter, r *http.Request) | ||
| 122 | 122 | writeAPIError(w, http.StatusNotFound, "repo not found") |
| 123 | 123 | return reposdb.Repo{}, false |
| 124 | 124 | } |
| 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 { | |
| 130 | 126 | writeAPIError(w, http.StatusNotFound, "repo not found") |
| 131 | 127 | return reposdb.Repo{}, false |
| 132 | 128 | } |
internal/web/handlers/explore.gomodified51 lines changed — click to load
@@ -52,6 +52,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 52 | 52 | } |
| 53 | 53 | return social.PublicFeed(r.Context(), deps, cursor, limit) |
| 54 | 54 | }) |
| 55 | + if activeTab == "activity" && isExploreFeedFragmentRequest(r) { | |
| 56 | + h.renderFeedFragment(w, r, feed, hasNext, nextURL) | |
| 57 | + return | |
| 58 | + } | |
| 55 | 59 | if viewer.ID != 0 { |
| 56 | 60 | var err error |
| 57 | 61 | 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 | ||
| 73 | 77 | h.logger.WarnContext(r.Context(), "explore trending users", "error", err) |
| 74 | 78 | } |
| 75 | 79 | } |
| 80 | + if activeTab == "activity" && isExploreFeedFragmentRequest(r) { | |
| 81 | + h.renderFeedFragment(w, r, feed, hasNext, nextURL) | |
| 82 | + return | |
| 83 | + } | |
| 76 | 84 | |
| 77 | 85 | pageHeading := title |
| 78 | 86 | feedHeading := "Public activity" |
@@ -102,6 +110,7 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 102 | 110 | "TrendingRepos": trendingRepos, |
| 103 | 111 | "TrendingUsers": trendingUsers, |
| 104 | 112 | "Path": path, |
| 113 | + "UseHTMX": true, | |
| 105 | 114 | } |
| 106 | 115 | if err := h.render.RenderPage(w, r, "explore/index", data); err != nil { |
| 107 | 116 | if h.logger != nil { |
@@ -111,6 +120,24 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 111 | 120 | } |
| 112 | 121 | } |
| 113 | 122 | |
| 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 | + | |
| 114 | 141 | func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) { |
| 115 | 142 | items, err := load(parseFeedCursor(r), feedDisplayLimit+1) |
| 116 | 143 | if err != nil { |
internal/web/handlers/explore_test.goadded71 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.gomodified31 lines changed — click to load
@@ -88,6 +88,10 @@ type Deps struct { | ||
| 88 | 88 | // workflow_dispatch endpoint (S41b). Auth-required + per-handler |
| 89 | 89 | // repo-write check. |
| 90 | 90 | 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) | |
| 91 | 95 | // RepoSettingsGeneralMounter registers the General/Access tabs and |
| 92 | 96 | // the deferred-tab placeholders (webhooks, keys, notifications, |
| 93 | 97 | // tags) under /{owner}/{repo}/settings/* (S32). Auth-required. |
@@ -255,9 +259,20 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http | ||
| 255 | 259 | }) |
| 256 | 260 | } |
| 257 | 261 | |
| 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 | + | |
| 258 | 273 | // Application routes — CSRF protected. Compress + Timeout live in |
| 259 | 274 | // 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. | |
| 261 | 276 | r.Group(func(r chi.Router) { |
| 262 | 277 | r.Use(middleware.Compress) |
| 263 | 278 | r.Use(middleware.Timeout(30 * time.Second)) |
internal/web/handlers/handlers_test.gomodified51 lines changed — click to load
@@ -9,6 +9,8 @@ import ( | ||
| 9 | 9 | "net/http/httptest" |
| 10 | 10 | "strings" |
| 11 | 11 | "testing" |
| 12 | + | |
| 13 | + "github.com/go-chi/chi/v5" | |
| 12 | 14 | ) |
| 13 | 15 | |
| 14 | 16 | func TestHandlers(t *testing.T) { |
@@ -135,3 +137,43 @@ func TestHealthzHEAD(t *testing.T) { | ||
| 135 | 137 | t.Fatalf("HEAD /healthz: status %d, want 200", rec.Code) |
| 136 | 138 | } |
| 137 | 139 | } |
| 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.gomodified61 lines changed — click to load
@@ -122,6 +122,8 @@ type profileContributionRepo struct { | ||
| 122 | 122 | Repo reposdb.Repo |
| 123 | 123 | OwnerSlug string |
| 124 | 124 | AllowIdentityFallback bool |
| 125 | + IsPrivate bool | |
| 126 | + IsProfileOwnedPublic bool | |
| 125 | 127 | } |
| 126 | 128 | |
| 127 | 129 | 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, | ||
| 296 | 298 | if !source.Repo.CreatedAt.Valid || created.Before(windowStart) || !created.Before(windowEnd) { |
| 297 | 299 | continue |
| 298 | 300 | } |
| 299 | - if source.Repo.OwnerUserID.Int64 != user.ID || source.Repo.Visibility != reposdb.RepoVisibilityPublic { | |
| 301 | + if !source.IsProfileOwnedPublic { | |
| 300 | 302 | continue |
| 301 | 303 | } |
| 302 | 304 | activity.addCreatedRepo(created, source) |
@@ -370,7 +372,7 @@ func newProfileActivityBuilder() *profileActivityBuilder { | ||
| 370 | 372 | } |
| 371 | 373 | |
| 372 | 374 | func (b *profileActivityBuilder) addCommit(day time.Time, source profileContributionRepo) { |
| 373 | - isPrivate := source.Repo.Visibility == reposdb.RepoVisibilityPrivate | |
| 375 | + isPrivate := source.IsPrivate | |
| 374 | 376 | fullName := source.OwnerSlug + "/" + source.Repo.Name |
| 375 | 377 | url := "/" + url.PathEscape(source.OwnerSlug) + "/" + url.PathEscape(source.Repo.Name) |
| 376 | 378 | if isPrivate { |
@@ -600,15 +602,15 @@ func (h *Handlers) addProfileThreadActivity(ctx context.Context, user usersdb.Us | ||
| 600 | 602 | } |
| 601 | 603 | deps := policy.Deps{Pool: h.d.Pool} |
| 602 | 604 | for _, row := range rows { |
| 603 | - if row.Visibility == issuesdb.RepoVisibilityPrivate && !user.IncludePrivateContributions { | |
| 604 | - continue | |
| 605 | - } | |
| 606 | 605 | ref := policy.RepoRef{ |
| 607 | 606 | ID: row.RepoID, |
| 608 | 607 | OwnerUserID: row.OwnerUserID.Int64, |
| 609 | 608 | OwnerOrgID: row.OwnerOrgID.Int64, |
| 610 | 609 | Visibility: string(row.Visibility), |
| 611 | 610 | } |
| 611 | + if ref.IsPrivate() && !user.IncludePrivateContributions { | |
| 612 | + continue | |
| 613 | + } | |
| 612 | 614 | if !policy.IsVisibleTo(ctx, deps, actor, ref) { |
| 613 | 615 | continue |
| 614 | 616 | } |
@@ -658,6 +660,8 @@ func (h *Handlers) profileContributionRepos(ctx context.Context, user usersdb.Us | ||
| 658 | 660 | Repo: repo, |
| 659 | 661 | OwnerSlug: ownerSlug, |
| 660 | 662 | AllowIdentityFallback: allowIdentityFallback, |
| 663 | + IsPrivate: repoRef.IsPrivate(), | |
| 664 | + IsProfileOwnedPublic: isProfileOwnedPublicRepo(repo, repoRef, user.ID), | |
| 661 | 665 | }) |
| 662 | 666 | } |
| 663 | 667 | |
@@ -697,6 +701,11 @@ func (h *Handlers) profileContributionRepos(ctx context.Context, user usersdb.Us | ||
| 697 | 701 | return out |
| 698 | 702 | } |
| 699 | 703 | |
| 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 | + | |
| 700 | 709 | func selectedContributionYear(query url.Values, currentYear int) int { |
| 701 | 710 | for _, key := range []string{"year", "from"} { |
| 702 | 711 | raw := strings.TrimSpace(query.Get(key)) |
internal/web/handlers/profile/profile_test.gomodified16 lines changed — click to load
@@ -640,8 +640,14 @@ func TestProfile_ContributionActivityIncludesCommitsReposIssuesAndPulls(t *testi | ||
| 640 | 640 | t.Errorf("missing %q in body: %s", want, body) |
| 641 | 641 | } |
| 642 | 642 | } |
| 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 | + } | |
| 645 | 651 | } |
| 646 | 652 | } |
| 647 | 653 | |
internal/web/handlers/profile/stars_tab.gomodified8 lines changed — click to load
@@ -107,7 +107,7 @@ func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user us | ||
| 107 | 107 | URL: "/" + url.PathEscape(ownerName) + "/" + url.PathEscape(row.RepoName), |
| 108 | 108 | Description: row.Description, |
| 109 | 109 | Visibility: string(row.Visibility), |
| 110 | - IsPrivate: row.Visibility == socialdb.RepoVisibilityPrivate, | |
| 110 | + IsPrivate: ref.IsPrivate(), | |
| 111 | 111 | StarCount: row.StarCount, |
| 112 | 112 | PrimaryLanguage: language, |
| 113 | 113 | LanguageColor: template.CSS(orgLanguageColor(language)), //nolint:gosec // server-side constant map. |
internal/web/handlers/repo/actions_atom.goadded123 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.goadded79 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.gomodified13 lines changed — click to load
@@ -117,6 +117,13 @@ func (h *Handlers) repoActionStepLogStream(w http.ResponseWriter, r *http.Reques | ||
| 117 | 117 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 118 | 118 | return |
| 119 | 119 | } |
| 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 | + }() | |
| 120 | 127 | |
| 121 | 128 | w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") |
| 122 | 129 | w.Header().Set("Cache-Control", "no-cache, no-transform") |
internal/web/handlers/repo/branches.gomodified110 lines changed — click to load
@@ -22,6 +22,7 @@ import ( | ||
| 22 | 22 | func (h *Handlers) MountRefs(r chi.Router) { |
| 23 | 23 | r.Get("/{owner}/{repo}/branches", h.branchesList) |
| 24 | 24 | r.Get("/{owner}/{repo}/tags", h.tagsList) |
| 25 | + r.Get("/{owner}/{repo}/compare", h.compareView) | |
| 25 | 26 | // Compare uses `...` as the base/head separator (matches GitHub). |
| 26 | 27 | // chi can't represent the literal `...` in a route param so we use |
| 27 | 28 | // a wildcard and parse server-side. |
@@ -209,21 +210,26 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) { | ||
| 209 | 210 | return |
| 210 | 211 | } |
| 211 | 212 | 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 == "" { | |
| 222 | 228 | base = row.DefaultBranch |
| 223 | 229 | } |
| 224 | - } | |
| 225 | - if base == "" { | |
| 226 | - base = row.DefaultBranch | |
| 230 | + if head == "" { | |
| 231 | + head = row.DefaultBranch | |
| 232 | + } | |
| 227 | 233 | } |
| 228 | 234 | |
| 229 | 235 | // 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) { | ||
| 231 | 237 | base = stripCrossRepoPrefix(base) |
| 232 | 238 | head = stripCrossRepoPrefix(head) |
| 233 | 239 | |
| 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 | + )) | |
| 265 | 267 | } |
| 266 | 268 | |
| 267 | 269 | // stripCrossRepoPrefix turns "fork:branch" into "branch". Local-only |
internal/web/handlers/repo/code.gomodified57 lines changed — click to load
@@ -4,6 +4,7 @@ package repo | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "bytes" |
| 7 | + "context" | |
| 7 | 8 | "errors" |
| 8 | 9 | "fmt" |
| 9 | 10 | "html/template" |
@@ -66,6 +67,17 @@ type codeContext struct { | ||
| 66 | 67 | subpath string // path inside the ref, no leading slash |
| 67 | 68 | } |
| 68 | 69 | |
| 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 | + | |
| 69 | 81 | // loadCodeContext does the resolve dance for tree/blob/raw/find. On |
| 70 | 82 | // any failure it writes the response and returns ok=false. |
| 71 | 83 | func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*codeContext, bool) { |
@@ -132,6 +144,26 @@ func (cc *codeContext) isBranchRef() bool { | ||
| 132 | 144 | return false |
| 133 | 145 | } |
| 134 | 146 | |
| 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 | + | |
| 135 | 167 | func (h *Handlers) canWriteRepo(r *http.Request, row reposdb.Repo) bool { |
| 136 | 168 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 137 | 169 | if viewer.IsAnonymous() { |
@@ -219,6 +251,7 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co | ||
| 219 | 251 | "Head": head, |
| 220 | 252 | "HeadFound": headFound, |
| 221 | 253 | "HeadAuthor": headAuthor, |
| 254 | + "BranchCompare": codeBranchCompareData(r.Context(), cc), | |
| 222 | 255 | "CommitCount": commitCount, |
| 223 | 256 | "README": template.HTML(readme.HTML), //nolint:gosec // sanitized by mdrender |
| 224 | 257 | "READMEPath": readme.Path, |
internal/web/handlers/repo/compare_ui.goadded361 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.goadded65 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.gomodified120 lines changed — click to load
@@ -190,17 +190,87 @@ func (h *Handlers) pullNewForm(w http.ResponseWriter, r *http.Request) { | ||
| 190 | 190 | base = row.DefaultBranch |
| 191 | 191 | } |
| 192 | 192 | 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, | |
| 201 | 200 | }) |
| 202 | 201 | } |
| 203 | 202 | |
| 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 | + | |
| 204 | 274 | // pullCreate handles POST /{owner}/{repo}/pulls. |
| 205 | 275 | func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) { |
| 206 | 276 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
@@ -266,18 +336,13 @@ func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request, | ||
| 266 | 336 | case errors.Is(err, issues.ErrBodyTooLong): |
| 267 | 337 | msg = "Body is too long." |
| 268 | 338 | } |
| 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, | |
| 281 | 346 | }) |
| 282 | 347 | } |
| 283 | 348 | |
internal/web/handlers/repo/repo.gomodified19 lines changed — click to load
@@ -133,12 +133,18 @@ func (h *Handlers) MountRepoActionsAPI(r chi.Router) { | ||
| 133 | 133 | r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch) |
| 134 | 134 | } |
| 135 | 135 | |
| 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 | + | |
| 136 | 142 | // MountRepoHome registers the root repository route plus product-tab shells |
| 137 | 143 | // that are intentionally public and read-gated like the Code tab. The |
| 138 | 144 | // two-segment route doesn't collide with the /{username} catch-all from S09; |
| 139 | 145 | // caller is responsible for ordering this BEFORE /{username}. |
| 140 | 146 | 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) | |
| 142 | 148 | r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog) |
| 143 | 149 | r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus) |
| 144 | 150 | r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun) |
internal/web/middleware/middleware_test.gomodified23 lines changed — click to load
@@ -61,6 +61,23 @@ func TestOptionalUser_PopulatesIsSuspended(t *testing.T) { | ||
| 61 | 61 | } |
| 62 | 62 | } |
| 63 | 63 | |
| 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 | + | |
| 64 | 81 | // TestOptionalUser_StaleEpochSkipsBind is the corollary: when the |
| 65 | 82 | // recorded session epoch doesn't match the current users.session_epoch |
| 66 | 83 | // (because the user logged out everywhere), the binding is skipped so |
internal/web/middleware/pat.gomodified148 lines changed — click to load
@@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "github.com/jackc/pgx/v5/pgxpool" |
| 16 | 16 | |
| 17 | 17 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" | |
| 18 | 19 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 19 | 20 | ) |
| 20 | 21 | |
@@ -24,9 +25,12 @@ var patAuthKey = ctxKey{name: "pat_auth"} | ||
| 24 | 25 | // the auth check passed via PAT, `Token != nil` and Scopes is the parsed |
| 25 | 26 | // scope list. Pure session callers see the zero value. |
| 26 | 27 | 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 | |
| 30 | 34 | } |
| 31 | 35 | |
| 32 | 36 | // PATAuthFromContext returns the resolved PAT auth state, or the zero |
@@ -38,6 +42,14 @@ func PATAuthFromContext(ctx context.Context) PATAuth { | ||
| 38 | 42 | return PATAuth{} |
| 39 | 43 | } |
| 40 | 44 | |
| 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 | + | |
| 41 | 53 | // PATConfig configures the PAT auth middleware. |
| 42 | 54 | type PATConfig struct { |
| 43 | 55 | Pool *pgxpool.Pool |
@@ -133,10 +145,19 @@ func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler { | ||
| 133 | 145 | }() |
| 134 | 146 | } |
| 135 | 147 | |
| 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 | + | |
| 136 | 154 | 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, | |
| 140 | 161 | }) |
| 141 | 162 | next.ServeHTTP(w, r.WithContext(ctx)) |
| 142 | 163 | }) |
@@ -146,6 +167,11 @@ func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler { | ||
| 146 | 167 | // RequireScope rejects with 403 if the request was authenticated via PAT |
| 147 | 168 | // and the token's scopes don't include required. Pure-session callers |
| 148 | 169 | // (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. | |
| 149 | 175 | func RequireScope(required pat.Scope) func(http.Handler) http.Handler { |
| 150 | 176 | return func(next http.Handler) http.Handler { |
| 151 | 177 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
@@ -155,9 +181,8 @@ func RequireScope(required pat.Scope) func(http.Handler) http.Handler { | ||
| 155 | 181 | return |
| 156 | 182 | } |
| 157 | 183 | 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)) | |
| 161 | 186 | return |
| 162 | 187 | } |
| 163 | 188 | next.ServeHTTP(w, r) |
@@ -165,6 +190,54 @@ func RequireScope(required pat.Scope) func(http.Handler) http.Handler { | ||
| 165 | 190 | } |
| 166 | 191 | } |
| 167 | 192 | |
| 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 | + | |
| 168 | 241 | // errNoCredentials is the sentinel that says "no Authorization header at |
| 169 | 242 | // all" — distinct from "Authorization present but malformed." |
| 170 | 243 | var errNoCredentials = errors.New("middleware: no credentials") |
@@ -201,11 +274,11 @@ func extractPAT(r *http.Request) (string, error) { | ||
| 201 | 274 | } |
| 202 | 275 | |
| 203 | 276 | // 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. | |
| 204 | 279 | func writePATChallenge(w http.ResponseWriter, realm, reason string) { |
| 205 | 280 | 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) | |
| 209 | 282 | } |
| 210 | 283 | |
| 211 | 284 | // remoteAddrFromRequest pulls the client IP for last_used_ip. Reuses the |
internal/web/server.gomodified7 lines changed — click to load
@@ -211,6 +211,7 @@ func Run(ctx context.Context, opts Options) error { | ||
| 211 | 211 | }) |
| 212 | 212 | } |
| 213 | 213 | deps.RepoHomeMounter = repoH.MountRepoHome |
| 214 | + deps.RepoActionsStreamMounter = repoH.MountRepoActionsStreams | |
| 214 | 215 | deps.RepoCodeMounter = repoH.MountCode |
| 215 | 216 | deps.RepoHistoryMounter = repoH.MountHistory |
| 216 | 217 | deps.RepoRefsMounter = repoH.MountRefs |
internal/web/static/css/shithub.cssmodified508 lines changed — click to load
@@ -7877,9 +7877,484 @@ button.shithub-repo-action { | ||
| 7877 | 7877 | border-bottom: 1px solid var(--border-default); |
| 7878 | 7878 | } |
| 7879 | 7879 | .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 | +} | |
| 7883 | 8358 | .shithub-settings-branches form label { display: block; margin: 0.5rem 0; } |
| 7884 | 8359 | .shithub-settings-branches form input[type=text], |
| 7885 | 8360 | .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 { | ||
| 10483 | 10958 | justify-content: center; |
| 10484 | 10959 | padding-top: 1rem; |
| 10485 | 10960 | } |
| 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 | +} | |
| 10486 | 10976 | .shithub-side-panel { |
| 10487 | 10977 | padding: 0 0 1.25rem; |
| 10488 | 10978 | margin-bottom: 1.25rem; |
internal/web/static/js/compare.jsadded60 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/_explore_feed_pagination.htmladded21 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.htmlmodified7 lines changed — click to load
@@ -36,6 +36,7 @@ | ||
| 36 | 36 | <link rel="stylesheet" href="/static/css/shithub.css"> |
| 37 | 37 | <link rel="stylesheet" href="/static/css/chroma.css"> |
| 38 | 38 | {{ 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 }} | |
| 39 | 40 | {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }} |
| 40 | 41 | </head> |
| 41 | 42 | <body class="shithub-body"> |
internal/web/templates/explore/feed_page.htmladded6 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.htmlmodified20 lines changed — click to load
@@ -97,7 +97,7 @@ | ||
| 97 | 97 | <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button> |
| 98 | 98 | </div> |
| 99 | 99 | {{ if .Feed }} |
| 100 | - <ol class="shithub-feed-list"> | |
| 100 | + <ol id="shithub-feed-list" class="shithub-feed-list" aria-live="polite"> | |
| 101 | 101 | {{ range .Feed }}{{ template "feed-row" . }}{{ end }} |
| 102 | 102 | </ol> |
| 103 | 103 | {{ else }} |
@@ -107,11 +107,7 @@ | ||
| 107 | 107 | {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }} |
| 108 | 108 | </div> |
| 109 | 109 | {{ 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" . }} | |
| 115 | 111 | {{ end }} |
| 116 | 112 | </main> |
| 117 | 113 | |
internal/web/templates/repo/compare.htmlmodified157 lines changed — click to load
@@ -1,49 +1,115 @@ | ||
| 1 | 1 | {{ 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" . }} | |
| 10 | 4 | |
| 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 }}&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> | |
| 24 | 16 | |
| 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> | |
| 37 | 25 | {{ 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> | |
| 41 | 83 | |
| 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> | |
| 48 | 114 | </section> |
| 49 | 115 | {{- end }} |
internal/web/templates/repo/pull_new.htmlmodified248 lines changed — click to load
@@ -1,46 +1,207 @@ | ||
| 1 | 1 | {{ 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> | |
| 27 | 31 | </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> | |
| 43 | 39 | </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> | |
| 45 | 206 | </section> |
| 46 | 207 | {{- end }} |
internal/web/templates/repo/pulls_list.htmlmodified8 lines changed — click to load
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | <header class="shithub-issues-head"> |
| 6 | 6 | <h1>Pull requests</h1> |
| 7 | 7 | <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> | |
| 9 | 9 | </div> |
| 10 | 10 | </header> |
| 11 | 11 | |
internal/web/templates/repo/tree.htmlmodified44 lines changed — click to load
@@ -67,6 +67,44 @@ | ||
| 67 | 67 | </div> |
| 68 | 68 | </header> |
| 69 | 69 | |
| 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 & 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 | + | |
| 70 | 108 | {{ if .Path }} |
| 71 | 109 | <nav class="shithub-code-crumbs" aria-label="Breadcrumb"> |
| 72 | 110 | {{ range $i, $c := .Crumbs }} |
internal/webhook/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
internal/worker/sqlc/models.gomodified268 lines changed — click to load
@@ -12,6 +12,184 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | ) |
| 14 | 14 | |
| 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 | + | |
| 15 | 193 | type CheckConclusion string |
| 16 | 194 | |
| 17 | 195 | const ( |
@@ -1601,6 +1779,54 @@ type AuthThrottle struct { | ||
| 1601 | 1779 | WindowStartedAt pgtype.Timestamptz |
| 1602 | 1780 | } |
| 1603 | 1781 | |
| 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 | + | |
| 1604 | 1830 | type BranchProtectionRule struct { |
| 1605 | 1831 | ID int64 |
| 1606 | 1832 | RepoID int64 |
@@ -1870,6 +2096,30 @@ type Org struct { | ||
| 1870 | 2096 | UpdatedAt pgtype.Timestamptz |
| 1871 | 2097 | } |
| 1872 | 2098 | |
| 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 | + | |
| 1873 | 2123 | type OrgGithubImport struct { |
| 1874 | 2124 | ID int64 |
| 1875 | 2125 | OrgID int64 |
scripts/lint-org-plan-boundary.shadded41 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.yamlmodified19 lines changed — click to load
@@ -241,3 +241,19 @@ sql: | ||
| 241 | 241 | emit_exact_table_names: false |
| 242 | 242 | emit_empty_slices: true |
| 243 | 243 | 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.